Skip to content

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:

<build-directory>/dist/<target>-<version>-windows-x64-symbols.zip

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.

cmake --preset macos-debug
cmake --build --preset macos-debug

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:

<build-directory>/dist/<target>-<version>-macos-universal-symbols.zip

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/.dSYM with 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.