Desktop Production Builds¶
Snowpulse has separate local-build and production-package workflows for
Windows and macOS. A normal target build remains quick and does not require
release credentials. The explicit package_<target>_windows or
package_<target>_macos target applies the stricter dependency, architecture,
signing, and artifact checks described below.
The generated desktop presets use CMake Presets schema 3 and require CMake 3.21 or newer. Manual project configuration retains Snowpulse's CMake 3.16 minimum.
The same desktop layer supplies runtime-switchable window modes, standard-layout gamepad input, native file dialogs, executable-relative assets, atomic saves, and persistent diagnostics. Windows and macOS share the public APIs even where their native implementations differ.
Game Metadata¶
snowpulse_add_game() uses the same identity and version values on every
desktop platform and accepts four optional desktop metadata values:
snowpulse_add_game(mygame
# Existing required settings omitted here.
DISPLAY_NAME "My Game"
BUNDLE_ID "com.example.mygame"
VERSION "1.2.0"
BUILD "42"
PUBLISHER "Example Studio"
COPYRIGHT "Copyright 2026 Example Studio"
WINDOWS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/branding/mygame.ico"
MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/branding/mygame.icns"
)
WINDOWS_ICON must be an .ico; MACOS_ICON must be an .icns. When an
icon is omitted, the target uses the operating system's generic application
icon. VERSION must contain three numeric components and BUILD must be
numeric so the same values can populate Windows resources and Info.plist.
Every package includes primary license/copying files for the vendored runtime
dependencies selected by the game. Windows places these under licenses/ in
the ZIP. macOS places them under Contents/Resources/Licenses/ in the app.
snowpulse_add_game() applies desktop packaging automatically. A standalone
editor or other engine executable can opt into the same helper after its
add_executable() call:
snowpulse_configure_desktop_target(myleveleditor
DISPLAY_NAME "My Level Editor"
BUNDLE_ID "com.example.myleveleditor"
ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets"
VERSION "1.2.0"
BUILD "42"
PUBLISHER "Example Studio"
)
Window Presentation¶
AppConfig::desktopWindow controls the initial desktop window. Zero width or
height uses the game's target resolution. displayIndex = -1 selects the
display containing the window, then the primary display when no display has a
usable overlap.
snowpulse::AppConfig config;
config.windowTitle = "My Game";
config.desktopWindow.width = 1280;
config.desktopWindow.height = 720;
config.desktopWindow.mode = snowpulse::WindowMode::Windowed;
config.desktopWindow.displayIndex = -1;
config.desktopWindow.resizable = true;
config.desktopWindow.decorated = true;
config.desktopWindow.vsync = true;
MyGame app(config);
return snowpulse::run(app);
The supported modes are:
| Mode | Behavior |
|---|---|
Windowed |
Decorated or undecorated window, optionally resizable |
BorderlessFullscreen |
Undecorated window covering the selected display at its current video mode, without a display-mode switch |
ExclusiveFullscreen |
GLFW fullscreen window using a mode selected from DisplayInfo::modes |
Use the AppContext wrapper for a runtime video-settings screen. It keeps the
stored configuration synchronized with the live window and reports whether
the transition succeeded. Entering fullscreen preserves the windowed bounds
so returning to Windowed restores the previous position and size.
auto* ctx = context();
auto config = ctx->desktopWindowConfig();
const auto displays = ctx->window()->displays();
if (!displays.empty() && !displays.front().modes.empty()) {
config.mode = snowpulse::WindowMode::ExclusiveFullscreen;
config.displayIndex = displays.front().index;
config.fullscreenMode = displays.front().modes.front();
}
if (!ctx->desktopWindowConfig(config)) {
// Keep the previous working choice in the settings UI.
}
Window::displays() returns display names, bounds, work areas, content scale,
the current mode, and enumerated modes. Window::state() returns the applied
mode, display, window/framebuffer sizes, scale, focus, minimized, and maximized
state. Window::setTitle() changes the native title. The older
AppContext::desktopWindowSize() and AppContext::vsync() methods remain
compatibility wrappers.
The desktop host emits WindowModeChangedEvent, WindowFocusChangedEvent,
WindowMinimizedChangedEvent, WindowContentScaleChangedEvent, and
DisplayConfigurationChangedEvent. Retain an event subscription for as long
as notifications are needed:
_modeSubscription = context()->events()->on<snowpulse::WindowModeChangedEvent>(
[](const snowpulse::WindowModeChangedEvent& event) {
// Refresh the video-settings UI from event.state.
});
Focus loss releases held input. Minimized windows suspend lifecycle audio and
wait for native events instead of rendering continuously. Refresh cached
display choices after DisplayConfigurationChangedEvent; if a fullscreen
display disappears, the runtime returns to a window on the primary display.
Gamepads and Bindings¶
Windows and macOS use GLFW's standard gamepad mapping. Input::gamepads()
lists connected mapped devices, while button and normalized-axis queries take
a stable device ID from that list:
auto* input = context()->input();
input->gamepadDeadzone(0.15f);
input->gamepadTriggerDeadzone(0.15f);
for (const auto& device : input->gamepads()) {
const bool confirm = input->gamepadButtonPressed(
device.id, snowpulse::GamepadButton::A);
const float horizontal = input->gamepadAxis(
device.id, snowpulse::GamepadAxis::LeftX);
(void)confirm;
(void)horizontal;
}
Stick and trigger deadzones default to 0.15. Values outside a deadzone are
rescaled to retain the full output range. Connections and disconnections emit
GamepadConnectionEvent.
Bindings can aggregate keyboard, mouse-button, and gamepad sources. A bind*
method replaces the existing source set; an add* method appends a source.
Input::kAnyGamepad chooses the strongest matching connected device.
auto* input = context()->input();
input->bindAction("confirm", snowpulse::KeyCode::Return);
input->addActionBinding("confirm", snowpulse::KeyCode::Space);
input->addGamepadActionBinding(
"confirm", snowpulse::Input::kAnyGamepad, snowpulse::GamepadButton::A);
input->bindAxis("move-x", snowpulse::KeyCode::A, snowpulse::KeyCode::D);
input->addAxisBinding("move-x", snowpulse::KeyCode::Left, snowpulse::KeyCode::Right);
input->addGamepadAxisBinding(
"move-x", snowpulse::Input::kAnyGamepad, snowpulse::GamepadAxis::LeftX);
Aggregated axes are clamped to [-1, 1]. Use clearActionBindings() and
clearAxisBindings() while rebinding. Input::releaseAll() is available for
custom modal/focus flows; the desktop host calls it automatically on focus
loss. Haptics and Steam Input are not included.
Native File Dialogs¶
AppContext::fileDialogs() returns IFileDialogService. Windows uses the COM
Common Item Dialog owned by the GLFW window; macOS uses NSOpenPanel and
NSSavePanel. Results contain native std::filesystem::path values, including
Unicode and long Windows paths.
The operations are synchronous and must run on the main/window thread. Do not open a panel from a loading worker or while destroying the window.
#include <platform/i_file_dialog.h>
snowpulse::OpenFileDialogRequest request;
request.title = "Open Layout";
request.initialDirectory = projectDirectory;
request.filters = {
{ "Snowpulse layout", { "json" } },
};
request.allowAllFiles = false;
auto result = context()->fileDialogs()->openFile(request);
if (result.isAccepted()) {
const std::filesystem::path path = *result.firstPath();
// Load path.
} else if (result.isFailed()) {
// Show result.error. Cancellation is not an error.
}
The service provides openFile(), openFiles(), saveFile(), and
selectFolder(). Save requests accept a suggested filename, default extension,
and filters; the native save panel performs overwrite confirmation. Check
isAvailable() before exposing the command on another platform. Results are
explicitly Accepted, Cancelled, or Failed. An accepted result always has
at least one path, cancellation has no path or error, and failure carries a
diagnostic string.
Runtime Paths and Diagnostics¶
Packaged assets do not depend on the process working directory:
| Data | Windows | macOS |
|---|---|---|
| Assets | <executable-directory>/assets/ |
<App>.app/Contents/Resources/assets/ |
| Saves and application data | %LOCALAPPDATA%/<bundle-id>/ |
~/Library/Application Support/<bundle-id>/ |
| Persistent log | application data logs/snowpulse.log |
application data logs/snowpulse.log |
| Marketing recordings | unavailable | ~/Movies/<bundle-id>/ by default |
Save replacement is durable and atomic on supported desktop filesystems. Slot
names use fixed-size, case-insensitive-safe SHA-256 filenames; saves written by
the older sanitized scheme remain readable. Recognized stale temporary save
files are removed on startup. The persistent log records the engine
version/build, OS/architecture, startup failures, and OpenGL GPU information.
A missing or incompatible HTTPS/TLS runtime is logged and aborts startup with a
nonzero result. Logs rotate at 2 MiB and keep snowpulse.log.1 through
snowpulse.log.3.
Windows¶
The canonical release compiler is MSVC x64 on a Windows host. The generated
presets expect Visual Studio 2022; packaging also needs dumpbin from a Visual
Studio Developer Command Prompt:
cmake --preset windows-release
cmake --build --preset windows-release
cmake --build --preset windows-package-release
Without a generated preset, use the equivalent multi-config commands and keep
the explicit --config Release on the package build:
cmake -S . -B cmake-build-windows-x64 `
-G "Visual Studio 17 2022" -A x64 `
-DSNOWPULSE_PRODUCTION_DEPENDENCIES=ON `
-DSNOWPULSE_MSVC_STATIC_RUNTIME=ON
cmake --build cmake-build-windows-x64 --config Release `
--target package_mygame_windows
The production preset enables SNOWPULSE_PRODUCTION_DEPENDENCIES and the
static MSVC runtime. Snowpulse builds GLFW, OpenAL Soft, curl, zlib, and the
other native runtime dependencies statically. curl uses Windows Schannel and
SSPI for HTTPS and the Windows trust store; OpenSSL is not needed on Windows.
The executable embeds its version information, requested-execution-level,
Windows compatibility, per-monitor-v2 DPI, long-path manifest, and optional
icon.
An embedding project with its own top-level CMakeLists.txt must select policy
CMP0091 before project() or any language is enabled so /MT also reaches
vendored dependencies:
cmake_minimum_required(VERSION 3.16)
if(POLICY CMP0091)
cmake_policy(SET CMP0091 NEW)
endif()
project(MyGame LANGUAGES C CXX)
A normal Windows target build refreshes <exe-directory>/assets after every
successful link. The refresh first removes the previous staged directory and
then copies the complete configured ASSETS_DIR; this deliberate replacement
prevents deleted or renamed source assets from lingering beside the game.
package_<target>_windows runs dumpbin /DEPENDENTS, rejects dynamic MSVC
runtime DLLs and unstaged non-system DLLs, creates the canonical payload, and
writes:
<build-directory>/dist/<target>-<version>-windows-x64.zip
<build-directory>/dist/<target>-<version>-windows-x64.zip.sha256
The ZIP contains the game executable, assets/, and licenses/. It does not
require the Visual C++ Redistributable. symbols_<target> keeps the PDB under
<build-directory>/symbols/ and creates the matching versioned archive:
The package target depends on it, so a production package cannot omit its matching symbol generation step.
The ZIP is intentionally not Authenticode-signed. Direct downloads can display Microsoft Defender SmartScreen or Smart App Control warnings, and organization policy can block them. In this workflow, self-contained means that runtime dependencies are present; it does not mean Windows treats an unsigned download as reputation-trusted.
macOS Local Builds¶
macOS game targets are .app bundles. Assets live under
Contents/Resources/assets, licenses under Contents/Resources/Licenses, and
the generated Info.plist contains the bundle ID, display name, version,
build, minimum OS, Retina declaration, category, copyright, and optional icon.
curl 8.15 and newer removed Secure Transport. Snowpulse therefore builds curl
with OpenSSL TLS and Apple SecTrust certificate verification. A local build may
use a host OpenSSL 3.x installation and emits a warning that the result is not
packageable. Set SNOWPULSE_ALLOW_SYSTEM_OPENSSL=OFF to make even local builds
require an explicit prefix.
No signing or notarization credentials are required for this local bundle.
macOS Production Package¶
Production packages require a universal static OpenSSL 3.5.8 installation
containing both arm64 and x86_64 slices. Point the preset at its installation
prefix (the directory containing include/, lib/libssl.a,
lib/libcrypto.a, and LICENSE.txt):
The command below assumes the current directory is an engine checkout or the
engine/ directory inside a packaged SDK. From a generated game root, use the
rendered command in that game's README.md; a vendored game resolves it to
third_party/snowpulse/scripts/build-openssl-macos.sh.
scripts/build-openssl-macos.sh \
--output /absolute/path/to/openssl-3.5.8-universal
export SNOWPULSE_OPENSSL_ROOT=/absolute/path/to/openssl-3.5.8-universal
cmake --preset macos-release-universal
cmake --build --preset macos-release-universal
The provisioner downloads the pinned source archive over HTTPS, verifies its
SHA-256, builds darwin64-arm64-cc and darwin64-x86_64-cc with no shared
libraries and a macOS 11 deployment target, merges both static archive pairs,
and installs the matching headers and license. It refuses to replace an
existing output prefix. Verify the result before configuring Snowpulse:
lipo -archs "$SNOWPULSE_OPENSSL_ROOT/lib/libssl.a"
lipo -archs "$SNOWPULSE_OPENSSL_ROOT/lib/libcrypto.a"
Both commands must report arm64 and x86_64 (the order is irrelevant).
Snowpulse deliberately does not download TLS source during configure. The
OpenSSL 3.5.8 source/build artifact must be supplied by the SDK/release
toolchain and built for the macOS 11 deployment target. Production configure
rejects a missing prefix, another OpenSSL version, a dynamic library, an
archive outside the explicit prefix, or an architecture set other than
arm64;x86_64.
Store App Store Connect credentials in the login keychain; never place secrets in CMake files:
xcrun notarytool store-credentials snowpulse-notary \
--apple-id "release@example.com" \
--team-id "TEAMID" \
--password "app-specific-password"
export SNOWPULSE_MACOS_SIGN_IDENTITY="Developer ID Application: Example Studio (TEAMID)"
export SNOWPULSE_MACOS_NOTARY_PROFILE="snowpulse-notary"
cmake --build --preset macos-package-release
Without the generated preset, configure and package directly:
cmake -S . -B cmake-build-macos-universal \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
'-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64' \
-DSNOWPULSE_PRODUCTION_DEPENDENCIES=ON \
-DSNOWPULSE_OPENSSL_ROOT="$SNOWPULSE_OPENSSL_ROOT"
cmake --build cmake-build-macos-universal --config Release \
--target package_mygame_macos
The values may instead be supplied through the same-named CMake cache entries.
SNOWPULSE_MACOS_ENTITLEMENTS can name an optional entitlements plist; the
default is intentionally empty. The direct-download package does not enable
App Sandbox.
package_<target>_macos verifies universal slices and Mach-O dependencies,
rejects host package-manager paths, signs the app with hardened runtime and a
secure timestamp, notarizes and staples the app, creates and signs a compressed
DMG, notarizes and staples that DMG, runs Gatekeeper validation, and writes:
<build-directory>/dist/<target>-<version>-macos-universal.dmg
<build-directory>/dist/<target>-<version>-macos-universal.dmg.sha256
symbols_<target> generates the matching .dSYM under
<build-directory>/symbols/ and the production archive below; the package
target depends on it:
Thin local builds label their symbol archive with the actual configured
architecture instead of universal.
Release Verification¶
Before publishing, test the artifact on a clean machine rather than only on the build host:
- Unpack/copy to a path containing spaces and Unicode, then launch from Explorer/Finder without changing the working directory.
- Load representative assets, audio, HTTPS content, and saves.
- Exercise open/save/folder dialogs, windowed/borderless/exclusive transitions, Alt-Tab, content-scale changes, and controller hot-plugging.
- On Windows, use a clean machine without Visual Studio, the Visual C++ Redistributable, MinGW, or another build environment. Windows verification must run on Windows; a macOS host cannot validate an MSVC package.
- On macOS, copy the app from the DMG into
/Applications, test with network access disabled once so the stapled ticket is exercised. Test both Apple Silicon and Intel. Developer ID signing and notarization require an Apple Developer membership and valid credentials. - Retain the ZIP/DMG checksum and matching PDB/
.dSYMwith the release record.
macOS Marketing Recorder¶
Recording is disabled in ordinary player builds and unavailable on Windows. Create a separate macOS marketing build when capture is needed:
cmake -S . -B cmake-build-macos-marketing \
-DCMAKE_BUILD_TYPE=Release \
-DSNOWPULSE_MACOS_MARKETING_RECORDING=ON \
-DSNOWPULSE_OPENSSL_ROOT="$SNOWPULSE_OPENSSL_ROOT"
This option enables DesktopRecorderConfig by default. With an empty
outputDirectory, recordings go under ~/Movies/<bundle-id>/, never inside
the signed .app. The first audio-enabled capture can request macOS Screen
Recording permission. Set captureAudio = false to make video-only output.
Keep this opt-in build separate from the ordinary signed player release.
Explicit Exclusions¶
This direct-download workflow does not implement an installer, Steam depot, Microsoft Store/MSIX, or Mac App Store package. Windows Authenticode signing, recording, MP4 playback, and MIDI are not included. Gamepad haptics and Steam Input are also deferred. OpenGL remains the first desktop release renderer; Metal is not required for this release.