Mobile
The engine runs on iOS through a host of its own. It shares the client, the netcode and the audio layer with the desktop build; what differs is the lifecycle it lives inside and the controls it is driven with.
The experimental iOS host opens the shared desktop main menu without loading a debug scene or starting a server. Start Game opens the shared map chooser and server settings; only its Commit action creates the selected on-device world and loopback owner, with the existing UDP listener enabled only when the captured server settings allow LAN play. Leave Game stops the listener and world, retires the rendered map through the shared scene teardown used by desktop, and returns to the menu. Clearing per-frame dynamic draws is not an unload: static map geometry and its GPU resources must also be released, with scene descriptors unbound first and temporal history discarded. The same controller and widgets drive desktop and touchscreen menu, chooser, console and Options. Touch-capable hosts select centralized logical target sizes and responsive layouts, independently of attached keyboards and controllers. Unsupported Options remain visible with explanations rather than pretending to change the platform. Connect joins a typed address for real: the shared Connect window’s parsed host:port is handed to the frame loop, which tears down the current session (including a hosted local server) and builds a remote one, remembering the server before the attempt and reporting failure through the same session card the launch path uses; a session that ends returns to the main menu. A connect <address> line typed into the console before a session exists takes the same path. Free-flight diagnostics require explicit --live or DH_PROBE_MODE=live. There is not yet a mobile remote-server browser, and no server discovery: favorites and recents are the list. Gamepad look now follows the player’s sensitivity, per-axis turn rates, deadzone and response curve; keyboard, stick and on-screen movement are prioritised rather than summed; buttons latch per tick, so a jump tapped between ticks still registers. The crosshair and speedometer draw from the same shared overlays and preferences desktop uses. So do the server’s toasts: every session routes its notices through the shared ClientNotices.Route into the queue of a closed EngineOverlay the shell paints into the overlay band, so a quiet rebuild’s Map rebuilt: … line and every refusal the server words reach the iPad as they reach the desktop. Options are honest about the platform: the whole Interface section and all Controls except Mouse apply live; Video beyond bloom and 3D resolution, and on-device asset-cache management, stay visible with explanations. Settings are saved to the canonical config/profile.toml. Retina UI and fonts render at native drawable resolution with logical control sizes. The independently saved 3D resolution defaults to automatic logical resolution (the previous scene pixel budget); explicit values from 0.25 to 1 select a fraction of native resolution. Zero restores automatic resolution. This does not change editor viewport sizing or Panini projection. Backgrounding pauses the retained world, and foregrounding rebases pacing and prediction rather than catching up elapsed background time. LAN clients may time out while the device is suspended and need to rejoin. This is not an always-on background server. Device support remains experimental and requires signed physical-device validation.
Mobile local startup opens the existing pre-session ClientLoadingModel before scheduling the server worker. It shows “starting the local server” while that worker runs, and holds the gameplay/loading gate until adoption, failure, or canceled completion. The hosted session then owns ordinary map-arrival progress; startup does not create a second progress model or report a game compilation that an AOT package never performs.
iOS lifecycle and diagnostics
Section titled “iOS lifecycle and diagnostics”Three permanent, documented knobs exist so a device that is not on a cable can still be told what to do and still be read afterwards. All three are keepers, not scaffolding for one hunt.
The runtime configuration line. Immediately after the first launch-trail breadcrumb, before any window or session log exists, the host logs one line on the ios channel and appends the same text to lifecycle-trail.log:
DH runtime: MONO_THREADS_SUSPEND=coop MONO_GC_PARAMS=nursery-size=512k,major=marksweep MONO_GC_DEBUG=verify-before-collections,check-remset-consistency,clear-at-gc,no-managed-allocator; os=iOS 27.0; model=iPhone; device=<device name>Every variable is read back through Environment.GetEnvironmentVariable, so unset means the runtime genuinely never saw it — a setting that failed to reach the process reads differently from one that did. The OS version, model and device name come from UIDevice, which is what lets a crash report be matched to the runtime configuration that produced it. See CONTRIBUTING.md, “iOS garbage collector: the audio thread and cooperative suspend”, for why MONO_THREADS_SUSPEND matters.
Audio on or off, from the console. client.audio.enabled (bool, default true) is the master switch for the whole audio stack, honored by both hosts at composition time through the shared ClientAudioComposition. False and the desktop client’s audio system stays null (every consumer already degrades to silence) and the iOS host never constructs its AVAudioSession adapter at all — so no playback device is opened and no real-time mixing thread is ever created. It is read once, while the host composes itself, so a change applies on the next launch; that is deliberate, because the point of switching audio off is that the thread never exists, which a live toggle could not promise. Either way one line is written on the audio channel at startup saying which way it went. The client.audio.* volume buses remain the live control.
The automated join. DH_PROBE_CONNECT=<host>:<port> makes the iOS host submit the very same connect request the Connect window submits, DH_PROBE_CONNECT_AFTER seconds after the main menu is up (default 10). The address goes through the shared ServerEndpoint parser, so a probed address and a typed one normalize to the same server. It logs DH probe: automated connect to <host>:<port> after <N> s when it is armed, and again when it submits. Both variables are read once at startup, never polled.
Launch a trial with devicectl, which is also how the GC verifier flags reach the runtime:
xcrun devicectl device process launch --device <id> --console --terminate-existing \ --environment-variables '{"DH_PROBE_CONNECT":"192.168.8.186:27015","MONO_GC_DEBUG":"verify-before-collections,check-remset-consistency,clear-at-gc,no-managed-allocator"}' \ io.mltn.digitalheavenThat flag set is the one to trust. verify-before-collections on its own is racy against the inlined write barriers this AOT emits and reports “not found in remsets” on objects that are fine; with check-remset-consistency beside it the debug range-copy barrier is installed and a remset line is real, while the whole-heap walk’s invalid-pointer check names the holder object, its class and the field offset at the collection before the fatal one (Invalid object pointer ... at offset 112 in object ... (Halcyon.ProgressCard)). check-remset-consistency alone switches that walk off. nursery-canaries answers a different question, a native write past the end of an object. MONO_GC_PARAMS is baked into the binary by the SDK and cannot be changed from the launch environment; the runtime line above shows what the process actually received.
The collector shape probe. DH_PROBE_MODE=gc-shape runs no window at all: it holds a model with runtime-built strings in a dozen object shapes (a struct by value in a class, a generic struct latch, a class latch, the real widgets, holders either side of the large-object line, a struct nested in a struct, an array element, a corelib struct), makes the holders old with two full collections, runs one minor collection on one set and one major on another, and logs DH gc-shape/real: <shape>: <arm> => KEPT|LOST <fields> per row, plus measured instance sizes and a word scan that names which model field sits at which byte offset of a real widget. It exists because the iOS runtime loses the references inside a struct held by value across an assembly line, a shape the desktop runtime traces, so the desktop suite is blind to it; the models the cards carry are classes for that reason.
The trial harness pattern. Launch as above with the console attached and the output captured; wait about 45 seconds (10 to the menu, the join, and enough play for the first nursery collections); then kill -9 the devicectl process, because it ignores SIGTERM and will otherwise hold the terminal. Grep the captured output for not found in remsets, Invalid object pointer, broken_heap, Assertion at, and EXC_BAD / SIGSEGV. Six to twelve repetitions per configuration is the bar a differential claim has to clear; one clean run proves nothing about a race.
Crash reports without a cable. TestFlight crash submissions are retrievable over the App Store Connect API: GET /v1/apps/{app}/betaFeedbackCrashSubmissions lists them, and GET /v1/betaFeedbackCrashSubmissions/{id}/crashLog returns the report with logText inline. Engine/scripts/asc-crashes.py does both (listing by default, --log <id> [outPath] to save one); it shares its ES256 JWT minting with asc-builds.py through Engine/scripts/asc_token.py, so neither script carries a second copy and neither needs pip. Symbolicate an address from a saved report against the matching numbered build’s dSYM:
xcrun atos -o <numbered>/StartupProbe.app.dSYM/Contents/Resources/DWARF/StartupProbe \ -arch arm64 -l <load address from the report> <address>The iOS session log observes UIKit background entry synchronously rather than waiting for another SDL frame. It flushes and closes only the shared logger’s file sink; foreground activation reopens the original path in append mode. Console and UI sinks remain active. Records received while file output is suspended are omitted from that file, with a bounded omitted-record count reported on resume. Duplicate transitions are harmless, and a failed reopen leaves the file suspended for a later retry without throwing through UIKit. session file suspending and session file resumed markers support device validation. This mitigates one retained file-lock candidate behind a build-2 RunningBoard 0xdead10cc termination; it is not proof that every retained resource is suspension-safe. Pallet reader handles remain a separate investigation, and the retained server/world is not torn down by logging lifecycle changes. Lifecycle transitions are now logged on the ios channel rather than inferred: UIKit background/active, SDL active/inactive (with the handler’s own duration, so a suspend that blocks on a server tick is visible as an entry with no matching completion), window focus, the first accepted present after a resume, and a warning when the app spends five seconds on screen without one. Because SDL’s UIKit backend can miss a foreground or focus-gained edge, a gated frame loop is checked against UIApplication.ApplicationState: two seconds of gating while UIKit reports the app active logs a warning and forces the resume, so a foregrounded process can no longer sit alive and black. A launch that fails before the session log exists leaves no session folder at all, so an append-only lifecycle-trail.log beside the diagnostics directory records managed entry, session-log creation, every background/active notification, and the frame loop’s exit across launches. None of this is device-verified yet.
Quit on iOS. The shared menu offers Quit on the iOS host too, and choosing it ends the app for real: the host closes the SDL view, the ordinary teardown runs (audio session, network, renderer), the session log is flushed, and then the process exits. That last step is deliberate — iOS keeps a windowless process alive after its only view closes, which was the “black screen after Quit” symptom when Quit only closed the view. Hiding the link on iOS is not the alternative: a missing Quit is a bug, and Apple’s discouragement of self-termination is a distribution question rather than a reason to withhold the control. The Info.plist also carries ITSAppUsesNonExemptEncryption=false, which is what keeps a TestFlight upload from stalling on the export-compliance question.
Background-close diagnostics write guarded direct-stderr DH BG close metadata after the close attempt: marker/dispose HRESULTs, whether disposal returned, and managed handle IsClosed (not proof of kernel release). This precedes a read-only own-descriptor inventory capped at 1,024 descriptor numbers, 64 output rows and a cooperative 20 ms scan budget; an individual syscall/stderr write cannot be preempted by that budget. The old log descriptor number is checked first even if the cooperative budget has elapsed, including above the scan range (at most 1,025 probes total). An SDK-compiled Darwin shim uses F_GETFL, fstat and F_GETPATH without opening, duplicating, closing or unlocking descriptors. Output includes only descriptor/access/type/error metadata and allowlisted path classifications, never raw paths, filenames, hashes, contents or endpoints. DH BG end reports partial coverage and the stopping reason. Snapshots are non-atomic, descriptor numbers may be reused, and F_GETFL does not report advisory-lock ownership. A pre-dispose marker on its own does not prove closure, and has not been enough to account for a 0xdead10cc termination. No pallet open policy or server/world ownership changes accompany this diagnostic.
Window and input: SDL3
Section titled “Window and input: SDL3”Every host, desktop and phone, stands on one SDL3 layer (ppy.SDL3-CS, see CONTRIBUTING.md for the bundled SDL version). SdlRuntime.Start sets the process-wide hints once, SdlWindow is the window with its Vulkan surface and event pump, SdlGamepad is the one pad reader, and HalcyonKeyMap is the one key table. The iOS host hands the process to SDL’s UIKit delegate through SDL_RunApp (SdlMain, which also points the SDL import at the embedded SDL3.framework); the Android host is SDL’s own SDLActivity with a managed Main. LivePresentationProbe owns its loop: it drains the queue, then runs one frame, until the view closes.
SdlTouchLook watches the queue rather than draining it, so lifecycle events reach it inside UIKit’s own callback. It reports fingers, hardware key edges (as physical keys), committed text, the wheel, and the pen. A canceled finger (a system gesture) resets every gesture instead of completing a tap.
The pen is a mouse-like pointer (mltn’s ruling on #327). Above the glass it hovers, so the control under it lights exactly as under a cursor; the tip is the primary button and clicks, drags and selects text. It never drives the gameplay sticks or the editor’s finger camera, and leaving hover range takes the light off. It keeps one pointer id for as long as it stays in range. Tip pressure travels with every sample (TouchPointerSample.Pressure); nothing in the interface reads it yet, so the shell logs each stroke’s peak (DH pen: stroke ended; peak pressure=…) as the device proof that it arrives. A pen never also arrives as a finger: SDL’s pen-to-touch and pen-to-mouse hints are both off on a touch shell.
| Pen | iPad (Apple Pencil) | Android | Windows desktop | macOS desktop | Linux desktop |
|---|---|---|---|---|---|
| Hover lights the control under it | ❔ not yet run on a device | ❔ | ❔ as the mouse (SDL pen-to-mouse), not yet run with a pen | ❔ | ❔ |
| Tip clicks and drags | ❔ | ❔ | ❔ as the mouse | ❔ | ❔ |
| Pressure reaches the host | ❔ logged per stroke | ❔ logged per stroke | ❌ the desktop reads the pen as a mouse | ❌ | ❌ |
A desktop reads a pen and a touchscreen as the mouse, which is how its window always worked.
Shared touchscreen controls
Section titled “Shared touchscreen controls”Touchscreen movement and camera contacts use the shared Halcyon tree’s pointer-ID capture, not platform-specific gesture ownership. The movement ring rests at the lower left while gameplay is available. A touchdown in the bottom third and left 50% of a phone viewport (40% when the shortest logical viewport dimension is at least 600) anchors it once; its entire ring and knob fit inside the safe area plus padding. The anchor stays fixed during the gesture, movement continues outside the start region, and release returns it to rest. Contacts starting in the ignored region do not acquire movement later; camera acquisition remains in the right half. Movement and camera contacts remain independent, and a third contact can operate the shared bottom-right menu button. That button becomes Close menu while paused. Gameplay controls follow ClientUiState.CrosshairVisible, not the informational HUD gate, and disappear when gameplay input is unavailable. Lifecycle cancellation and event loss require fresh contact downs. The production MobileControlsView supplies both live controls and render specimens. Its passive joystick canvas updates after input dispatch, so first touchdown, drag, release and menu cancellation paint the current gameplay state without rebuilding the control tree.
Controls → Touchscreen exposes independent horizontal and vertical camera inversion. Both default to enabled, reversing the preceding touchscreen defaults; saved explicit values remain authoritative. client.touchInvertHorizontal and client.touchInvertVertical affect touch displacement only, before hardware look is mixed in. Desktop pointer ID 0 retains the existing capture API; native mobile adapters report contact IDs and ordered samples without assigning gameplay roles. Mouse and gamepad direction remain unchanged; missing preference keys use true, while an explicitly saved false is preserved in the existing profile. The pause/close control uses Halcyon’s reusable CircleButton, with a circular painted surface inside a separately sized hit target. See touchscreen controls for the gesture and persistence details.
Validation scope: managed compilation checks source integration, portable tests exercise shared input/capture and host lifecycle logic without a device, and rendered specimens demonstrate shared widget appearance. None proves signed-device interaction. The working TestFlight build is historical evidence for the earlier app, not a shipment of these new controls; this control revision has not yet been packaged and delivered to a physical device. Fresh AOT/package and device validation remain separate gates.
Experimental iOS source and setup
Section titled “Experimental iOS source and setup”The host is versioned at Engine/DigitalHeaven.Engine.iOS/DigitalHeaven.Engine.iOS.csproj.
It uses the existing shared Client, Server, Game, Net, Physics, Audio and Assets projects;
there is no second gameplay implementation. Its assembly remains StartupProbe to preserve
relocation behavior. Build this project explicitly; the general solution does not require an
Apple workload. The checked-in source does not include signing identities or native archives.
The verified toolchain was .NET SDK 10.0.300, Microsoft.iOS 26.5.10284 and Xcode 26.5,
with iOS 15.0 minimum and device ios-arm64 (not simulator). Select SDK/toolchain paths
externally through PATH, DOTNET_ROOT and DEVELOPER_DIR. Set TMPDIR, NuGet and CLI cache
locations to your chosen build volume before running tools. Restore requires the pinned
packages/workload already installed or a separately authorized dependency acquisition.
dotnet build Engine/DigitalHeaven.Engine.iOS/DigitalHeaven.Engine.iOS.csproj \ -c Release -r ios-arm64 \ -p:IosNativeRoot="$IOS_NATIVE_ROOT" \ -p:IosSteamAudioDependenciesRoot="$IOS_STEAMAUDIO_DEPENDENCIES_ROOT"IosNativeRoot contains box3d-ios-arm64/lib/libbox3d.a,
miniaudio-ios-arm64/lib/libminiaudio.a and steamaudio-4.8.1/lib/ios/libphonon.a.
IosSteamAudioDependenciesRoot contains the pffft, mysofa and zlib installation
subdirectories, each with its static library in lib/. The miniaudio and Box3D archives must
each carry the .built-from stamp their recipe (Engine/DigitalHeaven.Engine.iOS/Native/) writes
beside them: the build compares it with the tree’s sources and fails, printing the recipe command
to rerun, when an archive is older than the code that calls it. Supply any required CodesignKey
and CodesignProvision externally. Building does not register an app, upload to TestFlight,
install, launch, or start a server. Archive pins, platform/ABI/export checks, native recipe
prerequisites and remaining clean-machine reproduction gaps are documented in CONTRIBUTING.
Run each managed Tests/{TouchObserverTests,HardwareInputRearmTests,ImportAdapterTests}
project with dotnet run --project <project.csproj>. Set TMPDIR to an external scratch
location; the adapter creates an isolated test library there. python3 Tests/test-icon-compat.py
exercises mixed catalogs and incremental changes using installed Xcode tools. These tests do
not launch the app or contact a device. The original scratch host and signed candidates are
preserved separately; their physical validation is not automatically evidence for a relocated build.
Snapshot fragmentation closed the oversized-snapshot/UDP budget defect that was open when this host was relocated.
Experimental iOS icon build
Section titled “Experimental iOS icon build”The experimental host consumes Assets/Icons/Engine/ios.icon unchanged as ImageAsset items with AppIcon=ios. The layered Icon Composer package is compiled by Xcode, not replaced by a flattened source image. The current macios 26.5.10284 workload predates standalone icon support (PR 24722), so the external host carries a removable project-local IconComposerCompat.targets/.py adapter after the SDK targets import. It runs one asset compilation for all .icon and .xcassets roots, supplies the partial manifest and logical bundle resources to the standard SDK pipeline, and hashes the complete input list/content to detect edits, removals and renames. Raw icon JSON/SVG files are not bundle resources. The adapter is deliberately limited to local iOS builds, phone/tablet devices, and no ODR, alternate icons or accent-color configuration; unsupported configurations fail explicitly.
The minimum deployment target remains iOS 15.0; no workload upgrade or machine-wide SDK modification is required. Successful asset compilation does not prove iOS 15 runtime appearance or App Store acceptance. Remove the adapter after a separately approved workload implements PR 24722 and passes mixed-catalog, incremental edit/removal, primary-icon manifest and raw-source-exclusion checks; retain the standard ImageAsset/AppIcon declarations without duplicate automatic items.
iOS physics linkage probe (verified on iPad)
Section titled “iOS physics linkage probe (verified on iPad)”An external iOS C# host can exercise the shared PhysicsWorldFactory / IPhysicsWorld
without a renderer, map, ECS world or server. On iOS, the physics assembly’s existing native
resolver looks for Box3D symbols in the main program; the host must statically link the
pinned single-precision iOS arm64 archive with NativeReference Kind=Static and
ForceLoad=true. Desktop dynamic-library resolution is unchanged. Native declarations and
resolver registration remain exclusively in the physics assembly. The scratch host excludes
Physics’s copied desktop runtime files before Apple’s native publish classification, preventing
a macOS dylib from entering an iOS link without deleting it or changing desktop deployment.
It also derives ReferenceNativeSymbol function retention items from the existing shared
bindings, since ForceLoad alone does not put runtime-looked-up functions in Apple’s explicit
export list. Check the final executable’s exports, not just the input archive’s symbols.
The scratch StartupProbe’s --physics (or DH_PROBE_MODE=physics) reuses the dynamic-prop
test fixture: drop a 1 m, 35 kg cube, check gravity velocity, step 240 settling ticks at
1/60 s, check floor height and sleep, ray-query the cube and overlap-query it through the
shared native-to-managed callback, then check impulse velocity and wake. It disposes and repeats before emitting DH iOS probe: shared physics smoke PASS.
Confirm the native pin, precision, platform and exports before a signed build; require
numerical logs and that final marker from the device run under an external 30-second
watchdog. The runner must terminate UIKit after the callback returns, including on success.
Verified on iPad through the ordinary shared factory, without
scratch-forced initialization or a diagnostic trim descriptor. Both worlds passed the numerical,
overlap-callback and disposal checks: first-tick Y velocity -0.16350001 m/s, resting Y
0.49993095 m, resting speed 0, ray fraction 0.37499997, and impulse delta X velocity 2 m/s.
All 49 native exports were present in the final executable, and external app cleanup completed.
The signed build had 0 errors and the baseline 41 warnings; the desktop physics build had
0 errors/0 warnings and all 94 physics tests passed.
The shared factory orders the existing binding initializer before a non-inlined native-using constructor path, retaining one resolver and allocator initialization owner. This is a scoped static-physics smoke result, not general iOS gameplay, audio or simulator support.