Skip to content

visionOS App

M T edited this page Oct 4, 2026 · 1 revision

visionOS App

native/EngineVision is the visionOS application target, shipped as HaloVision with the display name "Halo Vision". It is the process that hosts everything else. A SwiftUI App declares a full immersive space, which is the launch scene, plus two auxiliary windows. An Objective-C runtime file (EngineVisionRuntime.m) starts the statically translated Halo engine (host_run) on one dedicated 1.5 GB-stack worker thread. That same file implements the platform side of the host's presentation interface (metalwin_*), which receives frames and panorama layers from the Direct3D9 Bridge. Swift code then reads engine state, frames and panorama leases through a small C bridge (EngineVisionBridge.h). It also finds and imports the owned game files, keeps saves under Application Support, and exposes live presentation settings.

This page covers the app shell, startup, the bridge API, asset storage, settings, the runtime state machine, and the project and plist configuration. Two other pages cover the rendering side: Immersive Presenter explains how frames reach the headset, and Diagnostics and Telemetry covers the report files.

Source files

File Role
project.yml XcodeGen spec: one application target, sources, resources, frameworks, compiler flags, Info.plist keys
Info.plist Extra plist keys merged with the generated ones: game-controller profile, file sharing
Resources/PrivacyInfo.xcprivacy Privacy manifest
Resources/ThirdPartyNotices.txt Bundled third-party license text (MojoShader and others)
Sources/HaloEngineVisionApp.swift @main SwiftUI app, scenes, EngineAppSession startup owner, setup window UI
Sources/EngineRuntimeModel.swift Main-actor observable mirror of the C runtime state, polled every 0.2 s
Sources/EngineVisionBridge.h C API between Swift and the runtime (states, frames, panorama, telemetry, pointer, metalwin_*)
Sources/EngineVisionRuntime.m Implements the bridge: engine worker thread, device defaults, frame/panorama publication, zero-copy GPU pool, profile and core telemetry collection
Sources/audio_session.inc AVAudioSession coordinator, included into the runtime file
Sources/EngineVision-Bridging-Header.h Exposes haptics.h, gamecontroller.h, halo_settings.h, panorama.h, frame_pacer.h, the bridge and the diagnostics bridge to Swift
Sources/EngineAssets.swift Game-file catalog, bundled-payload manifest, import, verification, save preservation, EngineAssetStore
Sources/EngineSettingsView.swift "Presentation" settings window bound to HaloSettings
Sources/EngineFrameTexture.swift Immutable Metal snapshot of the latest flat BGRA frame
Sources/EngineFrameView.swift MTKView preview of the flat frame inside the setup window
Sources/EngineHaptics.swift Forwards audio-derived haptic events to the host's controller (see Haptics)
Sources/EngineMenuInput.swift Look-and-pinch menu input state machine (used by the presenter)
Sources/EngineImmersive*.swift, EnginePanorama*.swift, EngineWorldCadence.swift, EngineLayerAlignment.swift See Immersive Presenter and Layer Alignment
Sources/EngineDiagnostic*.swift/.h/.m, EngineCoreTelemetry.swift, EngineDeepTelemetry.swift, EngineGatherDiagnostics.swift See Diagnostics and Telemetry
Tests/AssetValidationMain.swift, BundledAssetValidationMain.swift, MutableAssetPreservationMain.swift, RuntimeFrameHarness.m, AudioSessionRecoveryValidation.m, MenuInputValidation.swift, FrameTextureValidation.swift Tests for the code on this page (see Testing)

Architecture at a glance

flowchart TB
    subgraph App["HaloEngineVisionApp (SwiftUI App)"]
        IS["ImmersiveSpace id HaloEngineImmersive<br/>CompositorLayer, .full immersion<br/>defaultLaunchBehavior .presented"]
        WS["WindowGroup HaloSetup<br/>EngineContentView<br/>defaultLaunchBehavior .suppressed"]
        WP["WindowGroup HaloSettings<br/>EngineSettingsView<br/>defaultLaunchBehavior .suppressed"]
    end
    SES["EngineAppSession (@MainActor)<br/>0.2 s refresh timer"]
    ASSETS["EngineAssetStore"]
    MODEL["EngineRuntimeModel<br/>0.2 s refresh timer"]
    IMM["EngineImmersiveSession"]
    DIAG["EngineDiagnostics"]
    BRIDGE["EngineVisionBridge.h (C)"]
    RT["EngineVisionRuntime.m"]
    WORKER["engine_worker pthread<br/>1.5 GB stack, QOS_CLASS_USER_INTERACTIVE<br/>host_run(halo.exe, root)"]
    HOST["EngineHost + translated engine<br/>(d3d9.c calls metalwin_*)"]

    IS -- "startImmersive(layer)" --> SES
    WS --> ASSETS
    WS --> MODEL
    WS --> IMM
    SES --> ASSETS
    SES --> MODEL
    SES --> IMM
    MODEL --> DIAG
    MODEL --> BRIDGE
    IMM --> BRIDGE
    BRIDGE --> RT
    RT -- "pthread_create" --> WORKER
    WORKER --> HOST
    HOST -- "metalwin_present / _gpu / _dropped" --> RT
Loading

SwiftUI app and scenes

HaloEngineVisionApp (HaloEngineVisionApp.swift:9-54) owns one @StateObject EngineAppSession and declares three scenes:

Scene ID Content Launch behaviour Notes
ImmersiveSpace EngineImmersiveSession.spaceID = "HaloEngineImmersive" CompositorLayer(configuration: EngineImmersiveLayerConfiguration()), whose closure calls session.startImmersive(layer) .presented .immersionStyle(selection: .constant(.full), in: .full) and .upperLimbVisibility(.hidden). Watches scenePhase to flush diagnostics.
WindowGroup "HaloSetup" EngineContentView .suppressed Default size 1180 x 760. Dismisses itself when immersiveActive becomes true.
WindowGroup "HaloSettings" EngineSettingsView .suppressed Default size 620 x 820. Opened from the setup window's "Presentation settings" button.

There is no RealityKit content. The immersive scene is a CompositorServices CompositorLayer, and the app draws into it with Metal (see Immersive Presenter). The app's preferred default scene role is CPSceneSessionRoleImmersiveSpaceApplication (project.yml), so the game itself is the launch scene.

Why the setup window is suppressed

The comment at HaloEngineVisionApp.swift:35-40 gives the reason. visionOS keeps an app's windows visible inside a full immersive space, and the setup window has no place in the game. The app therefore never opens it. visionOS shows it on its own when the immersive space could not present at launch. In that case the window opens the space itself as soon as the engine has a frame: .onChange(of: engine.frameSequence) at L253-L261 runs at most once per showing of the window (autoOpened).

Scene phase and diagnostics

On every scenePhase change (with initial: true) the app records app.scenePhase: <phase> in the immersive trace and calls engine.diagnostics.flush(reason:wait:). When the phase is .background, the flush waits until the report is on disk, because a report written as the app leaves the foreground may be the run's last (L23-L28).

EngineAppSession: startup is owned by the app

EngineAppSession (L56-L116) is a @MainActor ObservableObject that holds the three long-lived models:

Property Type Purpose
assets EngineAssetStore Finds, imports and verifies game files
engine EngineRuntimeModel Mirrors C runtime state; owns EngineDiagnostics
immersive EngineImmersiveSession Owns the compositor renderer lifetime
immersiveActive @Published Bool Deduplicated copy of immersive.$active, used to dismiss the setup window

init() calls enginevision_prepare_controller(), which runs hostgc_init() on the main thread so controller discovery is registered before game startup. It then subscribes to immersive.$active, runs refresh() once, and schedules refresh() every 0.2 s.

refresh() (L81-L115) does two jobs:

  1. It copies preparation and immersive counters (submitted, cancelled, tracking-loss, GPU completed/failed frames, GPU milliseconds, configuration string, last submission) into engine.diagnostics.
  2. It drives startup from assets.phase and sets the immersive loading card:
assets.phase Action Loading card title Shown
.checking none "Preparing Halo · Build N" yes, no progress
.importing none "Preparing Halo · Build N" yes, progress = copied/total
.missing, .failed none "Halo setup needs attention" yes
.ready engine.start(root: assets.rootURL) if engine.canStart "Starting Halo · Build N", or "Halo stopped · Build N" when the state is FAILED/STOPPED while frameSequence == 0 or failed

So the engine starts automatically, inside the 0.2 s timer, as soon as the assets are ready. No button press is needed. Build N comes from CFBundleVersion (L7).

Setup window (EngineContentView)

EngineContentView (L118-L290) has two parts: a 340-pt sidebar and an EngineFrameView preview of the flat engine frame. The sidebar contains:

  • The state label: Ready, Starting, Running, Stopped, Failed, or Unknown, mapped from ENGINEVISION_*. On failure the status text is shown.
  • "Import owned Halo folder" (.fileImporter, [.folder]). Only shown when the app bundle carries no game payload, and only enabled while the engine is idle.
  • Import progress, the current file, percent, the receipt's file count and byte total.
  • "Start Halo" / "Stop Halo" (engine.start / engine.stop).
  • "Enter immersive Halo" / "Leave immersive view". Disabled while opening or before the first engine frame.
  • "Recenter view", which calls immersive.recenter().
  • "Presentation settings", which calls openWindow(id: "HaloSettings").
  • "Retry local setup" for a failed bundled payload.
  • A controller status line and a "PS5 controller controls" help disclosure.
  • A "Diagnostics" disclosure (status, frame size, frame number, save status) and a ShareLink to report.json.

The view also declares .handlesGameControllerEvents(matching: .gamepad).

Opening and closing the immersive space is guarded by generation tokens (EngineImmersiveOwnership, described in Immersive Presenter):

  • ensureImmersiveOpen() (L277-L289) takes a token from immersive.prepare() and awaits openImmersiveSpace(id:). It only acts on the result if it still owns the token. .userCancelled and .error call finish() and set a status line.
  • toggleImmersive() dismisses when active, then calls finish() only if the token is still current.
  • .onChange(of: immersive.closeRequest) dismisses the space after the renderer loop ends, but only if canDismissClosedSession(generation) still holds.

EngineRuntimeModel: the runtime state machine

EngineRuntimeModel is a main-actor mirror of the C runtime, polled every 0.2 s:

Published Source
state enginevision_runtime_state()
status enginevision_copy_status into a 512-byte buffer
frameSequence, frameSize enginevision_frame_info
controllerStatus, diagnosticsStatus diagnostics.controllerStatus, diagnostics.saveStatus after diagnostics.sample(...)

canStart is state == IDLE. isActive is STARTING || RUNNING. start(root:) calls enginevision_start(root.path). If that returns nonzero and the runtime did not itself record FAILED, the status becomes "Engine start failed with POSIX status N." stop() calls enginevision_request_stop(). Each refresh() also feeds EngineDiagnostics.sample(...), the entry point for all report and timeline writing (see Diagnostics and Telemetry).

The C states are declared in EngineVisionBridge.h:13-19:

stateDiagram-v2
    [*] --> IDLE
    IDLE --> STARTING: enginevision_start accepted the root
    STARTING --> FAILED: path too long, stack or QoS rejected, pthread_create failed
    STARTING --> RUNNING: metalwin_init ok, or first metalwin_present*
    STARTING --> FAILED: metalwin_init invalid size
    RUNNING --> FAILED: frame buffer realloc failed
    RUNNING --> STOPPED: host_run returned 0
    RUNNING --> FAILED: host_run returned nonzero
    STOPPED --> [*]
    FAILED --> [*]
Loading

There is no transition back to IDLE. enginevision_start returns EALREADY once the state has left IDLE, and the status strings say "Relaunch the app to run it again." The engine is a one-shot, process-wide singleton.

Starting the engine: EngineVisionRuntime.m

Start sequence

sequenceDiagram
    participant Main as Main thread (EngineAppSession timer)
    participant Model as EngineRuntimeModel
    participant RT as enginevision_start
    participant Audio as audio_session_prepare
    participant W as engine_worker (pthread)
    participant Host as host_run
    participant D3D as d3d9.c Present
    participant Pub as metalwin_* (runtime)

    Main->>Model: assets.phase == .ready and canStart
    Model->>RT: enginevision_start(rootPath)
    RT->>RT: HALO_VIDMODE / HaloRenderResolution -> HALO_CMDLINE_EXTRA "-vidmode W,H,60"
    RT->>RT: HALO_PANORAMA_GPU -> gpu_enabled, host_panorama_set_gpu_sink()
    RT->>RT: append HALO_CMDLINE_EXTRA to host_command_line
    RT->>RT: lock, state IDLE? -> STARTING, runtime_root, runtime_exe = root/halo.exe
    RT->>Audio: audio_session_prepare() (visionOS)
    RT->>RT: pthread_attr: 1.5 GB stack, QOS_CLASS_USER_INTERACTIVE
    RT->>W: pthread_create + detach
    RT-->>Model: 0
    W->>W: record engine_worker_port
    W->>W: setenv device defaults (no overwrite), HALO_TEXTURE_PACK, HALO_SHADER_PACK
    W->>W: mr_set_fast_paths(HALO_DRAW_FASTPATH == "1")
    W->>W: host_core_telemetry = HALO_CORE_TELEMETRY != "0"
    W->>W: set_status(STARTING, "Loading Halo and preparing the renderer.")
    W->>Host: host_run(runtime_exe, runtime_root)
    Host->>D3D: game creates device, presents frames
    D3D->>Pub: metalwin_init(w, h) -> RUNNING
    D3D->>Pub: metalwin_present_gpu / _dropped / metalwin_present
    Host-->>W: result
    W->>W: STOPPED (0) or FAILED (nonzero)
Loading

enginevision_start(game_root)

EngineVisionRuntime.m:669-759:

  1. A NULL or empty root returns EINVAL.
  2. Video mode (visionOS only) (L681-L695). The mode comes from HALO_VIDMODE. If that is empty, it comes from the UserDefaults string HaloRenderResolution ("WxH", accepted only when 640 ≤ w ≤ 4096 and 480 ≤ h ≤ 3072). If neither is set, it is 2048,1536,60. The result is written to HALO_CMDLINE_EXTRA as -vidmode <mode> without overwriting an existing value. The comment records how the default was reached. 4:3 keeps the HUD layout. 2560x1920 was too much for the M2 with three views. 1920x1440 was used after stereo doubled the world passes. The default moved back up to 2048x1536 once the duplicate view was removed and the engine, not pixels, became the limit.
  3. Zero-copy panorama switch (L704-L710). HALO_PANORAMA_GPU defaults to on for visionOS unless set to 0, and to off on the Mac unless set to a non-0 value. When enabled, the runtime registers HaloPanoramaGPUSink { gpu_acquire, gpu_release, gpu_carry } with the host. The comment explains why the Mac can also run it: a lease that dropped three of ten layers once shipped as a black headset screen while every desktop composite looked fine.
  4. If HALO_CMDLINE_EXTRA is set and not already present, it is appended to host_cmdline_buf, which starts as "C:\Halo\halo.exe" -window -novideo (L29-L30).
  5. Under runtime_lock: if the state is not IDLE, return EALREADY. Otherwise copy the root and root/halo.exe into PATH_MAX buffers (overflow sets FAILED, "Imported game path is too long.", and returns ENAMETOOLONG), then set STARTING.
  6. visionOS: audio_session_prepare() (see Audio session).
  7. Thread attributes (L730-L749):
    • Stack size of exactly 1536 MiB, read back and checked. A mismatch is treated as EINVAL.
    • QOS_CLASS_USER_INTERACTIVE. The comment explains that without a QoS class the engine thread ran at default priority, eligible for efficiency cores, beside a 90 Hz user-interactive compositor. The headset ran the engine at about 4 fps in play while the Mac ran it at 16.
    • Any failure sets FAILED with "No unsafe fallback was started." There is no smaller-stack retry.
  8. pthread_create(engine_worker) and pthread_detach. Returns 0.

engine_worker

L608-L667:

  1. Stores its Mach port in engine_worker_port. The draw profile and core telemetry use it to read the thread's CPU time.
  2. On visionOS, applies device defaults with setenv(name, value, 0), so a value already present in the launch environment wins. Each value is logged as [device-config] NAME=value. A normal headset launch has no shell environment, so these defaults reproduce the configuration of the verified campaign probe:
Variable Device default Comment in source
HALO_FF3D 1
HALO_PANORAMA 1 see Panorama System
HALO_PANORAMA_DENSE 1
HALO_PANORAMA_WORLD_FP 1 world-space first-person arms in the side bands
HALO_PV_SKIN_FAST 1 exact-token skinning
HALO_NATIVE_GATHER 1 native light/shadow gathers; 0 keeps the translated path
HALO_DRAW_FASTPATH 1 static geometry reuse, capped at 128 MiB (see Geometry Fast Paths)
HALO_PAD2KEY 1
HALO_A10_AUTOPLAY 0
HALO_UNLOCK_CAMPAIGN 1
HALO_AUDIO_VOICE_TRACE 1 bounded five-second DirectSound summaries
HALO_CTLLOG 1
HALO_HSC_TRACE 120
HALO_HSC_TRACE_MAX 128
HALO_CORE_TELEMETRY 1 measurement only; 0 restores the pre-telemetry paths
  1. Calls mr_set_fast_paths(getenv("HALO_DRAW_FASTPATH") == "1") on the engine thread before any draws. An early diagnostics snapshot may already have cached the renderer switch, so it is applied explicitly here (L646-L650).
  2. Points HALO_TEXTURE_PACK and HALO_SHADER_PACK at the bundled TextureMods.hvt and ShaderMods.hvs, again without overwriting (see Visual Mods Pipeline).
  3. Decides host_core_telemetry once: on unless HALO_CORE_TELEMETRY starts with 0.
  4. Sets status "Loading Halo and preparing the renderer.", runs host_run(runtime_exe, runtime_root) (host.c:625), stores the exit code, and moves to STOPPED or FAILED with one of three messages.

The metalwin_* platform interface

d3d9.c calls an interface named after the macOS window (metalwin). On the Mac it is implemented by EngineHost/metalwin.m. On visionOS, EngineVisionRuntime.m implements it and creates no UIKit or AppKit objects:

Function visionOS behaviour
metalwin_init(w, h, title) Rejects sizes outside 1..8192 (FAILED). Otherwise sets RUNNING, "Renderer ready ... waiting for the first engine frame."
metalwin_present(bgra, w, h) Byte path (L531-L595). Copies the flat frame into latest_frame (realloc on growth; failure sets FAILED and requests quit) and increments latest_sequence. When host_panorama_frame reports a complete frame of matching size, it copies all HALO_PANORAMA_LAYERS layers into latest_panorama and fills panorama_info. A FLAT status also advances gpu_flat_sequence, so a menu supersedes any pending world frame.
metalwin_present_gpu(slot, w, h) Zero-copy path (L423-L457). Builds the frame's EngineVisionPanoramaInfo, calls the frame pacer (host_frame_pacer_present) with no lock held, then mr_commit_async(gpu_published, p). The slot is published from the command buffer's completion handler.
metalwin_present_dropped(w, h) A world frame the pool could not take (L459-L478). Increments the sequence and records an incomplete panorama state without touching the last published zero-copy frame.
metalwin_should_close() host_quit_requested != 0
metalwin_poll() No-op

All three present functions call engine_thread_sample() first (L67-L88). It records which CPU the engine thread is on and, if core telemetry is on, adds one frame-split sample (see Diagnostics and Telemetry).

The panorama pool internals (slot states, carry, publication ordering) are summarised in Immersive Presenter. The host-side producer is documented in Panorama System.

Locking and threads

Thread What it does here
Main EngineAppSession / EngineRuntimeModel timers, SwiftUI, enginevision_start, all enginevision_* reads
engine_worker host_run, every metalwin_*, engine_thread_sample
Metal completion thread gpu_published (publishes a pool slot)
Compositor render thread enginevision_panorama_gpu_latest/release, enginevision_frame_info/copy_latest_frame, enginevision_pointer, enginevision_menu_active
HaloVision.audio-session serial queue Audio session transitions and a 250 ms watchdog

All runtime state (runtime_state, runtime_status, frames, panorama info, pool slots) is guarded by one os_unfair_lock runtime_lock. The pacer wait in metalwin_present_gpu happens with the lock released ("nothing is locked while it waits"). The display-latch and publish-lag samples are lock-free _Atomic ring buffers (L97-L115).

Bridge API reference (EngineVisionBridge.h)

Every function below is implemented in EngineVisionRuntime.m unless noted.

Lifecycle and status

Function Line Behaviour
int enginevision_start(const char *game_root) h:33 Starts the one engine instance. The root must contain halo.exe. Returns 0 only after the worker thread exists. Error codes: EINVAL, EALREADY, ENAMETOOLONG, or the pthread error.
void enginevision_prepare_controller(void) h:35 hostgc_init() on the main thread, before game startup
void enginevision_request_stop(void) h:36 Sets host_quit_requested = 1. The engine stops at its next host boundary.
int enginevision_runtime_state(void) h:37 One of ENGINEVISION_IDLE..FAILED (0..4)
int enginevision_exit_code(void) h:38 host_run return value once it has returned
void enginevision_copy_status(char *, size_t) h:39 Copies the 512-byte human-readable status
int enginevision_menu_active(void) h:147 host_dinput_menu_active(): 1 while the engine has a menu up
void enginevision_pointer(float u, float v, int on_panel, int action) h:189 Forwards to host_pointer_set. (u, v) is normalised with (0,0) at top left. Action -1 = HOST_POINTER_CANCEL, 0 = hover/preview, 2 = queue one left-click. Taps stay queued until the engine cursor reaches the target. This is not a held-button API.

Flat frame

Item Line Notes
EngineVisionFrameInfo { sequence, width, height, byte_count } h:21-26
bool enginevision_frame_info(out) h:43 True when a byte frame exists. sequence advances on every present, including zero-copy ones.
bool enginevision_copy_latest_frame(dst, capacity, out) h:44-45 Race-safe info/copy split: returns false if capacity is smaller than the newest frame, and the caller retries after fresh info. out is always filled.
EngineFrameDigest / enginevision_frame_digest(out) h:28-29 At most 1024 strided pixel samples of the byte frame (alpha masked): FNV-1a style hash, non-black count, count differing from the first pixel. Used by diagnostics to show the engine is drawing.

Panorama (copy path)

Item Line Notes
EngineVisionPanoramaInfo h:47-69 sequence, width/height, byte_count (0 for zero-copy frames), per-layer projection_x/y, source_epoch, flat_sequence, scene_epoch, per-layer layer_epoch, status (0 flat/menu, 1 complete panorama, 2 incomplete world), failure_reason, stereo, per-layer viewport u_min/v_min/u_max/v_max, layer_pose[layer][9] (position, forward, up), cut_epoch. Arrays are indexed by layer; the zenith and nadir sit at layers 5 and 6.
bool enginevision_panorama_info(out) h:72 Copies one coherent publication. True only when byte_count > 0. Fields are returned even on false.
bool enginevision_copy_panorama(dst, capacity, out) h:73 Copies all layers' bytes atomically with the info

Panorama (zero-copy)

Item Line Notes
EngineVisionPanoramaGPUSnapshot { slot, info, textures[HALO_PANORAMA_LAYERS] } h:80-84 Textures are unretained id<MTLTexture> pointers, valid while leased
bool enginevision_panorama_gpu_enabled(void) h:85 The HALO_PANORAMA_GPU decision
bool enginevision_panorama_gpu_latest(out) h:86 Leases the newest ready slot (one lease). Also stamps the display latch for the frame pacer.
void enginevision_panorama_gpu_release(int32_t slot) h:87 Call from the completion handler of the command buffer that sampled the slot
EngineVisionPanoramaGPUStats / enginevision_panorama_gpu_stats(out) h:93-99 published, dropped, publish_failed, carry_copies, carry_failed, publish_superseded, latest_slot, slot_state[3], slot_leases[3], last_error[128]. These counters tell apart "the compositor could not lease a frame" and "the engine never made one".

Profiling and telemetry

Item Line Notes
EngineVisionDrawProfile / enginevision_draw_profile(out) h:104-145 Cumulative engine-thread profile: passes and their time, readback, draws, vertex/submit/upload time, the game's Sleep calls and yields, haptic onsets, panorama budget (extra passes per two frames, tier 0-3, busy seconds), gaze pointer frames and cursor, engine-thread CPU ns, frames per CPU number (16 buckets), P/E core counts, Metal pipeline compile/wait/background/prewarm/archive counters, audio watchdog rebuilds. Field meanings are in Diagnostics and Telemetry.
EngineVisionCoreTelemetry / enginevision_core_telemetry(out) h:151-184 Frame split, ticks, thread scheduling, process rusage and power counters. Returns false with a zeroed output while HALO_CORE_TELEMETRY=0 or before the engine starts. See Diagnostics and Telemetry.

Diagnostics bridge (EngineDiagnosticsBridge.h)

enginevision_diagnostic_sample(EngineDiagnosticSample *) and enginevision_capture_host_log(path) are declared in a separate header and implemented in EngineDiagnosticsBridge.m. Both are documented in Diagnostics and Telemetry.

Audio session (audio_session.inc)

The file is included into EngineVisionRuntime.m and compiled only for visionOS. audio_session_prepare() (audio_session.inc:62-140) runs once (dispatch_once) and does the following:

  • Creates the serial queue HaloVision.audio-session.
  • Moves the DirectSound stall watchdog onto a 250 ms timer on that queue (host_dsound_watchdog_set_external). Previously the watchdog ran once per engine frame, so it slept through the very stalls it exists for (level loads, engine stops).
  • Observes interruptions, media-services resets (which force a queue rebuild), foreground and background transitions, and route changes.
  • Activates synchronously with reason startup.

audio_session_resume sets the Playback category and activates the session. If needed it rebuilds the output and resumes the queue. On failure it pauses the output and retries the whole transaction no sooner than 2 s later from the tick. Coming back to the foreground clears an interruption's shouldResume = NO, because returning to the game is the player's own action. Going to the background pauses cleanly, because the app has no audio background mode. Route logging is limited to 64 lines and records port types only, never device names or UIDs (L19-L28). The mixer side is covered in Audio System.

Asset discovery, import and storage (EngineAssets.swift)

What counts as a valid game

EngineAssetCatalog (EngineAssets.swift:11-24) requires these files at minimum sizes, and requires halo.exe to have a specific SHA-256 (the PC 1.10 executable):

Path Minimum bytes
halo.exe 2,000,000 (and SHA-256 must equal EngineAssetCatalog.haloSHA256)
maps/ui.map 2,000,000
maps/a10.map 90,000,000
maps/bitmaps.map 300,000,000
maps/sounds.map 200,000,000
shaders/vsh.bin 30,000
shaders/fx.bin 900,000
strings.dll 1,000,000
halo-vision-registry.txt 100

halo-vision-registry.txt is a small text file that setup creates locally to stand in for the Windows registry values the game reads. It is private to the user and never published (see Setup Wizard and Win32 Compatibility Layer).

Storage layout (app container)

Location Contents
<bundle>/GamePayload/ and <bundle>/GamePayloadManifest.json Optional. A locally built app can carry the owned game files (project.yml adds them as optional resources from .setup/; public source releases never include them).
Application Support/HaloVision/Game/ Root for a user-imported folder (no bundled payload)
Application Support/HaloVision/PackagedGames/<payloadID>/ Root for a bundled payload, one directory per manifest payloadID
<root>/engine-vision-import.json EngineImportReceipt (importedAt, fileCount, totalBytes, executableSHA256, payloadID)
<root>/Users/Player/Documents/My Games/Halo/ EngineAssetImporter.bundledMutableRoot: profiles, checkpoints and playlists the game writes through its emulated Windows user directory
Application Support/HaloVision/.Game-import-<UUID>/ Staging during import, removed on failure
Application Support/HaloVision/.Game-backup-<UUID>/ Previous install while the new one is promoted
Documents/Diagnostics/<runID>/ Diagnostics (see Diagnostics and Telemetry)

Because the payload directory is keyed by payloadID, a new game payload never overwrites an older version's files or saves (L411-L413).

EngineAssetStore phases

EngineAssetStore (L378-L470) publishes phase ∈ {checking, missing, importing, ready, failed(String)}, plus detail, copied, total, currentFile, receipt, and rootURL. hasBundledPayload is true if either GamePayload or GamePayloadManifest.json exists in the bundle.

flowchart TD
    A["EngineAssetStore.init"] --> B{"hasBundledPayload?"}
    B -- yes --> C["prepareBundledGame()"]
    B -- no --> D["restore(): validate(HaloVision/Game)"]
    D -- ok --> R["ready"]
    D -- error --> M["missing"]
    M -- "user picks folder" --> I1["importFolder(source)"]
    I1 --> P1["importing (detached task)"]
    P1 -- ok --> R
    P1 -- error --> F["failed"]
    C --> L["load + check manifest"]
    L --> V{"validateInstalledBundle(PackagedGames/payloadID)"}
    V -- ok --> R
    V -- not ready --> P2["importing (detached, verify SHA-256 of every file)"]
    P2 -- ok --> R
    P2 -- error --> F
    F -- "Retry local setup" --> C
Loading

Bundled manifest checks

EngineBundledManifest.check() (L80-L112) rejects the manifest unless all of the following hold:

  • formatVersion == 1.
  • payloadID is 64 lowercase hex characters.
  • fileCount > 0 and equals files.count.
  • totalBytes > 0.
  • executableSHA256 is the catalog hash.
  • Every path is relative, with no empty, . or .. components, no backslash or NUL, is not engine-vision-import.json, and is unique case-insensitively.
  • Every hash is valid.
  • Sizes sum without overflow to totalBytes.
  • Every required file is present at its minimum size.
  • The manifest's halo.exe hash matches.

Import algorithm

EngineAssetImporter.importFolder(_:destination:bundleManifest:progress:) (L149-L228):

  1. Build the entry list.
    • With a manifest, entries come straight from manifest paths. FileManager URLs are not used for this, because /var and /private/var aliases once cost file names. Each entry must be a regular, non-symlink file of the exact size.
    • Without a manifest, collect() enumerates the chosen folder, skipping hidden files and symlinks. It requires every file to resolve inside the folder and canonicalises case: top-level maps/shaders directories become lowercase, and top-level files are lowercased. Case-insensitive duplicates are rejected. Required files and the halo.exe hash are checked before copying begins.
  2. Copy to a staging directory .Game-import-<UUID> beside the destination, in 1 MiB chunks. Task.checkCancellation() runs between chunks, and progress is reported per chunk. With a manifest, every file's SHA-256 is computed during the copy and must match.
  3. Bundled repair only: preserveMutableGameData(from: destination, into: staging) copies the player's entire Users/Player/Documents/My Games/Halo tree from the live install over the staged defaults. The tree is rejected if it contains symlinks or non-file, non-directory items.
  4. Write the receipt into staging atomically, then validate(staging).
  5. promoteStaging (L291-L320):
    • If no install exists, move staging into place.
    • Otherwise rename the old install to .Game-backup-<UUID>, then move staging into place.
    • If that move fails, rename the backup back. If the rollback also fails, the error names the backup directory so the saves can be found.
    • Remove the backup on a best-effort basis. A cleanup failure does not fail the import.
  6. Any error removes the staging directory and rethrows.

Mutable asset preservation

validateInstalledBundle checks the receipt's payloadID, fileCount and totalBytes against the manifest. It then calls validatePreparedFiles, which checks the size of every manifest file except those under bundledMutableRoot (isMutableInstalledPath, case-insensitive prefix match). This means a save that has changed size does not make an otherwise complete install look broken and trigger a repair (L241-L257). The design rationale is in the comment at L116-L119: the bundled copies are first-launch defaults, and after installation the whole subtree belongs to the player.

File sharing

Info.plist sets UIFileSharingEnabled and LSSupportsOpeningDocumentsInPlace, so the app's Documents folder is visible in Files. That folder holds Diagnostics/ and, in simulator capture mode, SimCaptures/. The importer reaches folders the user picks through security-scoped URLs (startAccessingSecurityScopedResource).

Settings UI (EngineSettingsView.swift)

The window is titled "Presentation". EngineSettingsModel (L10-L60) initialises every control from halo_settings_get. The host's defaults are themselves read from environment variables, so scripted runs behave the same. Every didSet calls push(), which writes the whole HaloSettings struct back with halo_settings_set. Changes take effect on the next rendered frame without an engine restart. The underlying store and its environment variables are documented in Runtime Settings.

Section Control Range / step HaloSettings field Host env default
Depth "Render both eyes" toggle stereo_separation > 0 HALO_STEREO (on unless 0)
Depth "Eye separation" slider 45-80 mm, step 1 stereo_separation = mm / 1000 / 2 / 3.048 (Halo world unit is 10 ft = 3.048 m) HALO_STEREO_IPD 63 mm
Field of view "Vertical coverage" 90-175°, step 5 panorama_vfov (radians) HALO_PANORAMA_VFOV 105°
Joins "Line up older views" layer_align HALO_LAYER_ALIGN off (see Layer Alignment)
Frame rate "Keep at least" 20-30 fps, step 1 panorama_target_fps HALO_PANORAMA_TARGET_FPS 30
Frame rate "Even frame cadence" frame_pacing HALO_FRAME_PACING off (see Frame Pacing)
Surround "Brightness" 0-2x, step 0.05 backdrop_brightness HALO_BACKDROP 1.0
Surround "Spatial front-end menu" spatial_shell HALO_SPATIAL_MENU on
Menus "Head pointer" gaze_pointer HALO_GAZE_POINTER off
Sound "Your own weapon" 0-24 dB, step 1 self_gain_db HALO_SELF_GAIN_DB 18
Haptics "Strength" 0-2x, step 0.05 haptics_strength HALO_HAPTICS (1 unless 0)
Haptics "Test pulse" button, "N detected · M played" hostgc_play_haptic(1, 0.5)
(bottom) "Render resolution (next launch)" picker 1280x960, 1600x1200, 1920x1440, 2048x1536 @AppStorage("HaloRenderResolution"), default "2048x1536" read in enginevision_start; HALO_VIDMODE wins

The haptics status line and its counters refresh every 0.5 s from EngineHaptics.state, eventsTaken and eventsPlayed.

Flat frame preview

EngineFrameTexture.refresh(expectedSequence:) (EngineFrameTexture.swift:13-45) reads enginevision_frame_info, copies the bytes with enginevision_copy_latest_frame, and validates the size. It then allocates a new bgra8Unorm texture for every new sequence and exposes a bgra8Unorm_srgb view of it. Calling replace() on the existing texture would race a command buffer from the previous compositor frame that is still sampling it. EngineFrameView (EngineFrameView.swift) is a 60 fps MTKView that letterboxes the frame with a single full-screen triangle. It also attaches a GCEventInteraction with handledEventTypes = .gamepad and receivesEventsInView = false.

Project configuration (project.yml)

Setting Value
Project name / target EngineVision, type: application, platform: visionOS
Deployment target visionOS 26.0
PRODUCT_NAME / module HaloVision / EngineVision
PRODUCT_BUNDLE_IDENTIFIER org.example.halovision (placeholder; local setup supplies a real identifier and signing, see Device Preparation and Signing)
MARKETING_VERSION / CURRENT_PROJECT_VERSION 1.0.3 / 103
TARGETED_DEVICE_FAMILY 7 (Vision)
Swift 5.0, SWIFT_STRICT_CONCURRENCY: minimal
Optimisation GCC_OPTIMIZATION_LEVEL 0 (base), 2 (Release)
Bridging header Sources/EngineVision-Bridging-Header.h
Header search paths Sources, third_party/mojoshader, native/EngineHost, native/EngineReuse, native/build/engine-reuse/whole-exe
OTHER_CFLAGS -frounding-math -ffp-contract=off -DENGINE_FLAT_MEMORY=1 -DHALO_ARM64_FENV_FAST=1, MojoShader profile switches (Metal only) (see x87 Floating Point, Shader Translation)
OTHER_LDFLAGS -lm
Frameworks Foundation, SwiftUI, UIKit, Metal, MetalKit, QuartzCore, CompositorServices, ARKit, CoreHaptics, GameController, AudioToolbox, AVFAudio
Native sources compiled in EngineHost/host.c, shims_kernel32.c, shims_misc.c, directsound.c, directsound_mixer.c, haptics.c, halo_settings.c, pointer.c, vorbis_shim.c, d3d9.c, texture_decode.c, metalshader.c, dinput8.c, ddraw.c, resources.c, overrides.c, gamecontroller.m, threading.c, metalrenderer.m, MojoShader core and Metal profile, and the generated engine chunk_*.c, engine_bundle.c, engine_imports.c
Resources ThirdPartyNotices.txt, PrivacyInfo.xcprivacy, .setup/VisualMods/{TextureMods.hvt, ShaderMods.hvs, CEnshineSources.zip, VisualModsManifest.json}, and optionally .setup/GamePayload (folder) and .setup/GamePayloadManifest.json

tools/build_engine_vision.py runs xcodegen generate --spec native/EngineVision/project.yml and xcodebuild. When Xcode's visionOS platform runtime is missing it falls back to a "direct xros toolchain" build, which compiles the same sources with xcrun --sdk xros clang and writes its own binary Info.plist. Only the direct path writes a HaloBuildID key (a UTC timestamp), which diagnostics report as buildID (build_engine_vision.py:187). See Build System.

Info.plist keys

Generated from project.yml (GENERATE_INFOPLIST_FILE: YES) plus the checked-in Info.plist:

Key Value Source
CFBundleDisplayName Halo Vision project.yml
GCSupportsControllerUserInteraction YES project.yml
GCRequiresControllerUserInteraction { visionOS: true } Info.plist
GCSupportedGameControllers [{ ProfileName: ExtendedGamepad }] Info.plist
NSHandsTrackingUsageDescription "A pinch chooses the menu item you are looking at; hands are never recorded." project.yml
NSWorldSensingUsageDescription "Head tracking keeps the curved original-engine screen aligned with your chosen reclined pose; aiming remains on the controller." project.yml
UILaunchScreen_Generation, UIApplicationSceneManifest_Generation YES project.yml
UIApplicationPreferredDefaultSceneSessionRole CPSceneSessionRoleImmersiveSpaceApplication project.yml
UIFileSharingEnabled, LSSupportsOpeningDocumentsInPlace true Info.plist

The direct build path writes an equivalent dictionary with a slightly different hands-tracking string ("A pinch selects the menu item you are looking at."). It also sets UISceneInitialImmersionStyle = UIImmersionStyleFull explicitly.

Privacy manifest

PrivacyInfo.xcprivacy declares:

  • NSPrivacyTracking = false, no tracking domains, no collected data types.
  • One accessed-API category: NSPrivacyAccessedAPICategoryFileTimestamp, with reasons C617.1 and 3B52.1.

The importer and history files read file metadata (size, type), and the watcher relies on file modification dates.

The runtime keeps no network code in this target. Diagnostics stay in the app container until the user shares them (see Diagnostics and Telemetry).

Failure modes and recovery

Failure Where What the user sees
Missing or undersized game file, wrong halo.exe hash EngineAssetImporter.validate/checkedEntries missing / failed phase; loading card "Halo setup needs attention" with the error text
Bundled payload incomplete or tampered manifest check, per-file SHA-256 failed; "Retry local setup" reruns prepareBundledGame()
Promotion failure promoteStaging Old install restored; if the rollback also fails, the error names .Game-backup-<UUID>
1.5 GB stack or QoS rejected enginevision_start FAILED, message says no unsafe fallback was started
Invalid back buffer size metalwin_init FAILED
Frame buffer allocation failure metalwin_present FAILED, quit requested
Engine exits engine_worker STOPPED (status 0) or FAILED with host status; relaunch required
Immersive space fails to open ensureImmersiveOpen Setup window status line; window remains

Testing

Test What it asserts How it is run
Tests/AssetValidationMain.swift preflight(folder) passes on a real game folder; prints file count, bytes and hash Manual CLI (asset-validation GAME_FOLDER)
Tests/BundledAssetValidationMain.swift Preflight across /var vs /private/var aliases and a symlinked .app; install and reuse; rejects ../escape, duplicate paths, wrong total, stale payloadID, a corrupted hash, and a deleted leaf file. Progress is monotonic and ends at totalBytes. Manual, against a local .setup payload
Tests/MutableAssetPreservationMain.swift A resized save does not invalidate an install; a missing immutable file does; a repair carries changed and new profile files into staging; a failed promotion leaves the original save intact Manual
Tests/RuntimeFrameHarness.m start(NULL) gives EINVAL; start reaches STOPPED with exit 0, or FAILED with the "No unsafe fallback" message; a second start gives EALREADY; frame info/copy bounds; digest counts; oversized present ignored Manual (links the runtime with a stub host_run)
Tests/AudioSessionRecoveryValidation.m Includes the real audio_session.inc with mock AVAudioSession, clock and DirectSound hooks. Activation retry is bounded to 2 s; rebuild retry after reset; background and interruption gates; a stopped runtime blocks resume. tools/run_source_checks.py (macOS)
Tests/FrameTextureValidation.swift A refresh between encoding and executing a GPU read does not mutate the in-flight texture; a duplicate sequence is not reallocated Manual (Metal)
Tests/MenuInputValidation.swift See Immersive Presenter run_source_checks.py

The full list of what tools/run_source_checks.py runs is on Testing and Source Checks.

Related pages

Clone this wiki locally