diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
new file mode 100644
index 0000000000..5d4f362318
--- /dev/null
+++ b/.github/workflows/ios.yml
@@ -0,0 +1,55 @@
+name: iOS
+
+on:
+ push:
+ branches: [main, apple/ios-web-multiplayer]
+ paths-ignore: ['**.md', 'docs/**', 'research/**']
+ pull_request:
+ branches: [main, apple/ios-web-multiplayer]
+ paths-ignore: ['**.md', 'docs/**', 'research/**']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ios-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: macos-26
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+ - name: Toolchain
+ run: |
+ test "$(uname -m)" = arm64
+ brew install cmake ninja llvm lld sdl3 pkgconf
+ xcodebuild -version
+ /opt/homebrew/opt/llvm/bin/clang --version
+ /opt/homebrew/opt/lld/bin/ld.lld --version
+ - name: ABI, memory, audio, and XISO regressions
+ run: python3 tools/ios_test.py
+ - name: Browser and native room adapter regressions
+ run: node --test tools/tests/*.test.cjs
+ - name: Build unsigned device IPA
+ run: python3 tools/ios_build.py --unsigned --jobs 3 --ipa dist/Halo-CE-iOS-unsigned.ipa
+ - name: Build ARM64 simulator app
+ run: python3 tools/ios_build.py --simulator --jobs 3
+ - name: Archive simulator app
+ run: ditto -c -k --keepParent build/ios/app-simulator/Release-iphonesimulator/HaloCE.app dist/Halo-CE-simulator.zip
+ - uses: actions/upload-artifact@v4
+ with:
+ name: halo-ce-ios-unsigned
+ path: |
+ dist/Halo-CE-iOS-unsigned.ipa
+ dist/Halo-CE-iOS-unsigned.ipa.sha256
+ if-no-files-found: error
+ retention-days: 14
+ - uses: actions/upload-artifact@v4
+ with:
+ name: halo-ce-ios-simulator
+ path: dist/Halo-CE-simulator.zip
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
new file mode 100644
index 0000000000..3ec094db29
--- /dev/null
+++ b/.github/workflows/web.yml
@@ -0,0 +1,95 @@
+name: Web
+
+# The web port (port/web/README.md): builds it with Emscripten for every
+# pushed commit. Production Pages uses the separate reviewed static branch;
+# an engine build alone must not replace the deployed launcher.
+on:
+ push:
+ paths-ignore:
+ - "docs/**"
+ - "research/**"
+ workflow_dispatch:
+
+concurrency:
+ group: web-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ EMSDK_VERSION: 6.0.10
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 40
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/cache@v4
+ with:
+ path: ~/emsdk
+ key: emsdk-${{ env.EMSDK_VERSION }}
+
+ - name: Install Emscripten
+ run: |
+ if [ ! -x ~/emsdk/upstream/emscripten/emcc ]; then
+ git clone --depth 1 https://github.com/emscripten-core/emsdk.git ~/emsdk
+ ~/emsdk/emsdk install "$EMSDK_VERSION"
+ fi
+ ~/emsdk/emsdk activate "$EMSDK_VERSION"
+ sudo apt-get install -y ninja-build
+
+ - name: Build
+ run: |
+ source ~/emsdk/emsdk_env.sh
+ python3 configure.py --release
+ ninja web
+
+ - name: Check browser invitation and cache handling
+ run: |
+ node --test tools/tests/*.test.cjs
+ node --test tools/test_web_quick_play.mjs
+ node --test tools/test_web_touch_controls.mjs
+ node tools/test_source_cache.mjs
+ clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -Ibuild/web/third_party/SDL3/include tools/test_web_scancodes.c port/web/src/web_scancodes.c -o build/test_web_scancodes
+ build/test_web_scancodes
+ clang -std=c11 -pthread -fsanitize=address,undefined -g -Iport/linux/src tools/test_web_net.c -o build/test_web_net
+ build/test_web_net
+ clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -g -Itools -Isource tools/test_quick_play.c -o build/test_quick_play
+ build/test_quick_play
+ clang -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined -g tools/tests/network-checkpoint-test.c -o build/test_network_checkpoint
+ build/test_network_checkpoint
+ python3 tools/test_web_migration_managers.py
+ python3 tools/test_web_connection_idle.py
+ python3 tools/test_network_migration_checkpoint_runtime.py
+ python3 tools/test_web_server_policy.py
+ python3 tools/test_network_player_rejoin.py
+ python3 tools/test_web_player_names.py
+ python3 tools/test_web_solo_host.py
+ python3 tools/test_network_pending_rejoin.py
+ python3 tools/test_network_client_update_lifecycle.py
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: web-runtime
+ path: build/web/site
+
+ relay:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - run: sudo apt-get update && sudo apt-get install -y gcc-multilib
+ - name: Test native P2P bridge with local host fixture
+ run: make -C port/relay test
+ - name: Test WebSocket session handling
+ working-directory: tools/browser-relay
+ run: |
+ npm ci --ignore-scripts
+ npm test
+ NATIVE_WORKER=../../port/relay/halo-relay-worker-test npm run test:native
diff --git a/BROWSER_LOCAL.md b/BROWSER_LOCAL.md
new file mode 100644
index 0000000000..6794272127
--- /dev/null
+++ b/BROWSER_LOCAL.md
@@ -0,0 +1,155 @@
+# Local browser runner
+
+Run the published [bnunu Apollo browser beta](https://bnunu.itch.io/apollobeta)
+locally with an opaque canvas fix, batched WebGL stream uploads, and render-state
+caches.
+
+For a hosted demo or to publish the same runtime on GitHub Pages, see
+[GITHUB_PAGES.md](GITHUB_PAGES.md).
+
+This is a runner for the publisher's pinned HTML build `19421784`, **not an
+Emscripten source-build target for this checkout**. The setup script verifies
+SHA-256 hashes before installing the runtime into ignored `build/web/` and
+records provenance in `build/web/download-provenance.json`. No game maps,
+disc images, downloaded runtime binaries, or Xbox SDK files are committed.
+
+## Start
+
+Requires Python 3.9+ and a recent Chrome with WebAssembly threads and WebGL 2.
+From the repository root:
+
+```sh
+python3 tools/setup_browser.py
+python3 tools/serve_browser.py
+```
+
+Open and select the disc image of your own Xbox copy
+of Halo: Combat Evolved. The publisher's importer stores the maps in this
+browser once; nothing is uploaded. Later visits reuse the stored maps and
+saves. Keep the same host and port to retain access to that storage.
+
+Alternatively, serve maps directly from an existing supported disc image:
+
+```sh
+python3 tools/serve_browser.py --image '/path/to/Halo.xiso.iso'
+```
+
+Then open and click **Play**. This route
+validates all 24 maps and supports NTSC build `01.10.12.2276` and PAL build
+`01.01.14.2342`. The server reads only the requested map bytes from the image;
+it does not extract an additional copy to disk. `run-browser.command` is a
+shell shortcut for the server and accepts the same arguments.
+
+The server binds to `127.0.0.1`, disables directory listings, serves the WASM
+MIME type, and supplies the cross-origin isolation headers needed by threads.
+Stop it with Ctrl-C. Re-run the setup script to restore the pinned runtime.
+
+The publisher's Asyncify compatibility build is available with
+`?build=asyncify` if the default JSPI runtime is unsupported.
+
+## Rendering fixes
+
+`port/web/opaque-canvas.js` requests `alpha: false` when creating the game
+context. The Xbox renderer often disables alpha writes; a transparent browser
+canvas otherwise hides valid rendered RGB content.
+
+`port/web/stream-batch.js` records GL commands and merges append-only vertex
+and index uploads before their draws. The supported rings are identified by
+Halo's exact allocation sizes and usage. Uniform data and upload bytes are
+snapshotted before the WASM heap changes; untouched gap bytes are preserved.
+Overlaps retain their original order, and readbacks, transfers, and presentation
+boundaries flush queued commands. GPU writes invalidate CPU shadow data.
+
+The browser build uses three 16 MiB vertex buffers and three 2 MiB index
+buffers. Batching avoids repeatedly modifying these large buffers between
+draws, at the cost of approximately 54 MiB of CPU shadow memory. The shaders,
+vertex values, published WASM, and engine JavaScript are unchanged.
+
+`replay-cache.js` skips unchanged uniforms, constant vertex attributes, and
+render state before the recorder copies their arguments. Uniform caches track
+overlapping array ranges and invalidate on alternate setters and program
+changes. `vertex-state-cache.js` separately tracks attribute enable bits per
+VAO and sampler bindings per texture unit. Neither cache removes draws or
+changes buffer uploads. Both run after the stream recorder is installed.
+
+Use `?replay_cache=0&vertex_state_cache=0` to compare with batching alone;
+either cache can also be disabled independently. Local helper script URLs carry
+content hashes so re-running setup picks up changed code despite browser caches.
+The local server also revalidates cached HTML and scripts on each visit.
+
+This wrapper relies on Halo's append-only allocator contract; it is not a
+general-purpose WebGL optimizer. Add `?batch_streams=0` to disable it for a
+comparison. The FPS HUD counts engine presents rather than animation callbacks.
+`?profile=1` enables GL call timing and adds measurement overhead.
+
+## Local validation
+
+On an Apple Silicon Mac in Chrome 153, with a 3022 × 1540 canvas and the
+Silent Cartographer campaign:
+
+| Scene | Original uploads | Batched uploads |
+| --- | --- | --- |
+| Early intro, frame range 10–80 | ~3.5 FPS | 21–29 FPS across two runs |
+| Later intro | scene-dependent | roughly 48–100 FPS in the first run |
+| First beach combat | not used for a matched comparison | roughly 25–30 FPS |
+
+The baseline included attribute telemetry. Samples were collected once per
+second; the early range contained 19 baseline readings and two or three batched
+readings. The observed improvement is approximately 6–8× for that opening
+scene, not a steady 120 FPS claim or a hardware-independent benchmark. Compare
+matching views and warm shaders; background windows and scene changes affect
+results. Textured rendering, firing, weapon switching, and pause/resume were
+checked. This does not validate a full campaign or multiplayer.
+
+A second rendering comparison on the same Mac used a 2450 × 1305 canvas,
+with the beach scene held at the same pause menu throughout. Diagnostic buttons
+changed the caches without reloading or moving the camera. Each row uses the
+last ten one-second readings after allowing the setting to settle:
+
+| State, in measurement order | Median FPS | Range |
+| --- | ---: | ---: |
+| Both caches enabled | 35.00 | 32.07–37.24 |
+| Render-state cache only | 32.57 | 31.82–35.61 |
+| Both caches disabled; stream batching retained | 27.78 | 24.75–29.39 |
+| Both caches re-enabled | 34.29 | 30.43–36.38 |
+
+This is a 23–26% improvement in that fixed rendering workload, not a claim
+about every gameplay scene. An independent fresh gameplay run of the faster
+render-state cache measured 35.36 FPS median over frames 2700–3300 versus
+32.67 with the earlier batched build; moving combat scenes are less controlled.
+The temporary live-toggle controls are not shipped. Larger command batches and
+pooled uniform snapshots did not improve the live comparison and were excluded.
+
+Recorder CPU overhead also fell by roughly 35% in a separate synthetic no-GPU
+benchmark. It is reproducible with
+`node tools/test_stream_batch.mjs --benchmark --baseline-ref=b9da047`; these
+numbers are not game FPS. Reload after WebGL context loss; complete context
+recovery is not validated by this runner.
+
+Run the regression checks without game data:
+
+```sh
+node tools/test_stream_batch.mjs
+node tools/test_replay_cache.js
+node tools/test_vertex_state_cache.cjs
+python3 -m unittest discover -s tools -p 'test_setup_browser.py'
+python3 -m unittest discover -s tools -p 'test_serve_browser.py'
+```
+
+## Controls and launch options
+
+- Click the canvas to capture the mouse; Esc releases it.
+- WASD moves, mouse aims, left click fires, Space jumps.
+- E uses, R reloads, F melees, Tab switches weapons, P pauses/resumes.
+- Enter/Space accepts menus; Backspace goes back.
+
+In the launcher's Options field, `(set terminal_render false)` hides beta
+debug text. To start the Silent Cartographer directly, use:
+
+```text
+(set terminal_render false);map_name levels\b30\b30
+```
+
+Options persist. Remove the `map_name` command to return to the main menu.
+The publisher build skips Bink movies and reports no network link; multiplayer
+is not verified by this runner.
diff --git a/BROWSER_NATIVE_INVITES.md b/BROWSER_NATIVE_INVITES.md
new file mode 100644
index 0000000000..0f80831c80
--- /dev/null
+++ b/BROWSER_NATIVE_INVITES.md
@@ -0,0 +1,53 @@
+# Browser invitations for native multiplayer
+
+The source-built browser preview accepts the desktop game's `halo://join/<44 hexadecimal characters>` invitations. The equivalent shareable link uses HTTPS:
+
+```
+https://fqlx.github.io/halo-ce-universal/#join=<44 hexadecimal characters>
+```
+
+The launcher can paste native invites and copy browser links. The fragment is not included in HTTP requests or referrer headers. An invite grants access to its host's current session; it stops working after that host exits. A Discord channel URL or an expired Discord rich invitation is not itself a game token.
+
+**Deployment status:** both public sites now serve the source-built runtime with the relay endpoint unset. Chrome has joined an actual release33 desktop host through a local relay and entered a two-machine Blood Gulch match. A public native relay still must be configured and tested before the public URL can join desktop games.
+
+## Why the relay is required
+
+Browsers cannot use the desktop build's raw UDP sockets. `port/web/site/gateway.js` carries the browser's virtual game sockets over a secure WebSocket. A session-specific worker in `port/relay` runs the existing native MQTT, encrypted P2P and KCP networking. It joins only the invited host and forwards game traffic; it does not run game simulation or need the ISO/maps.
+
+The browser receives the worker's native identity and peer address mapping before starting its network stack. Once the maps and invited host are ready, the launcher automatically discovers and joins that host's game. This uses the production `HALO_QUICK_PLAY=join` path with the authenticated host's virtual address, without navigating System Link menus. A host being offline or unreachable is shown separately from a relay connection. **Main menu** cancels quick play.
+
+Browser-only WebRTC rooms are also available in the source port. They have a different protocol and room code; putting a native invite into a browser room does not join the native host.
+
+## Build the browser
+
+Use Emscripten 6.0.10, Python 3 and Ninja:
+
+```sh
+source build/emsdk/emsdk_env.sh
+python3 configure.py --release
+ninja web
+python3 tools/build_multiplayer_pages.py --relay wss://YOUR-RELAY-HOST/join
+```
+
+The output is `dist/browser-multiplayer`. All paths are relative, so it can be served under a GitHub Pages project or a preview subdirectory. An empty `--relay` produces an explicitly unavailable desktop-join state. Configure only an operator-controlled relay URL; links cannot override it. Relay access keys are entered in the launcher and sent in the first WebSocket message, never embedded in the public site or invitation.
+
+The source-built engine includes current native network version 4. The existing `tools/build_pages.py` still packages the previous hash-pinned Apollo engine. Its WebAssembly has no multiplayer sockets, so adding a URL argument to that runtime cannot enable multiplayer.
+
+## Deploy the relay
+
+See [tools/browser-relay/README.md](tools/browser-relay/README.md) for build/run configuration and required limits. The relay needs a Linux host with outbound MQTT/STUN and UDP connectivity, and WSS termination. GitHub Pages serves static files and cannot run this process. Public deployments require an access capability and exact allowed browser origins. Private/reserved peer destinations are rejected by the production worker.
+
+Runtime-only updates can ship with the relay unset. Before enabling a public relay, verify browser-to-native gameplay through that deployed endpoint. Update both the game's static branch and the game subdirectory in the personal-site mirror, preserving the personal site's root files and CNAME.
+
+## Validation
+
+```sh
+node --test tools/tests/native-invite.test.cjs
+npm ci --prefix tools/browser-relay
+npm test --prefix tools/browser-relay
+make -C port/relay test
+```
+
+The native fixture exercises the real encrypted P2P path with a local broker and echo host. The frontend tests exercise framing, session permissions, worker lifetime and backpressure. These are transport tests, not a Halo match: launch a compatible native host, open a fresh invitation in the browser, discover it under System Link, join the lobby, and play together before marking crossplay verified.
+
+Verified locally on 2026-09-29: Chrome downloaded and cached all 24 maps, followed an HTTPS-style fragment invitation on loopback, authenticated the native host through the relay, joined System Link, and loaded Blood Gulch. The native host recorded two players on two machines, all machines loaded, and about 85 seconds of bidirectional gameplay packets; the browser rendered the match and reported live player updates. The tab subsequently disappeared and the host recorded connection loss; the cause of the tab disappearance is unknown. The native host was the official `build-33` Linux release (source `0ef2ed7`). This was a local, automated join test using `HALO_NETWORK_TEST=join`, not a public Internet/NAT test or a sustained gameplay/performance benchmark. Test arguments, game assets, native runtime packages and private invitations are excluded from source.
diff --git a/GITHUB_PAGES.md b/GITHUB_PAGES.md
new file mode 100644
index 0000000000..c5fd4a42a7
--- /dev/null
+++ b/GITHUB_PAGES.md
@@ -0,0 +1,177 @@
+# Play Halo on GitHub Pages
+
+- Game:
+- Mirror:
+
+Both sites serve the source-built browser port in `port/web`. The game runs
+in a Web Worker and uses WebAssembly threads and WebGL 2. The GitHub URL
+does not redirect to the personal domain.
+
+**September 29, 2026 release update:** automatic match startup, one-player
+System Link, host recovery, room switching and cached-launcher compatibility
+are combined with the Chrome/macOS presentation and repeated-rejoin fixes.
+Use each site's `deployment.json` for its current source commit, runtime and
+packaged file hashes. Cached launchers remain compatible with the prompt-free
+page while the service worker updates. Existing visitors can reload and choose
+**Update**; imported maps and saves are retained.
+
+Chrome-family browsers on macOS use owned RGBA frame transfers instead of the
+ImageBitmap serialization path that crashed in Chrome 153. Frame queues are
+bounded, empty bitmaps are rejected, and same-address reconnects retire the
+old RTC/native connection before accepting its replacement. Departed players
+release input ownership while retaining their scores. A distributed browser
+Free-for-All Slayer host can keep waiting when an opponent reloads or leaves.
+
+Historical tests on the earlier runtime `0780c5587f467c15` started a solo
+match and joined it with a second player, then hit renderer crashes
+("Aw, Snap", error 11). Those failures describe the old runtime. The later
+local crash/rejoin build passed an initial late join and five consecutive
+same-address reload/rejoin cycles; the same host stayed playable for over
+six minutes. That run predates combining these fixes with the host-recovery
+and room changes. The combined release passed `ninja web`, all 104 browser
+tests, cache and stream-recorder checks, and native lifecycle/restart tests
+under AddressSanitizer and UndefinedBehaviorSanitizer. These checks do not
+verify physical-iPhone gameplay or connections across different Internet/NAT
+networks.
+
+This replaces the pinned Apollo runtime, which froze during game startup
+in desktop WebKit 26.4. The worker-based port reached the Halo main menu
+with the same UI map in the September 29, 2026 test. Touch controls are
+available; startup and gameplay on a physical iPhone remain unverified.
+See [port/web/README.md](port/web/README.md) for requirements and controls.
+
+Browser rooms need `ui.map` and `beavercreek.map` before starting. The full
+main menu and desktop invites need the complete map set. Each player imports
+their own Xbox disc image through the launcher; the site does not provide
+maps or an ISO. Returning visitors reuse maps already stored in that browser.
+The September 29 local Chrome check used a previous version that downloaded
+only `ui.map` and `bloodgulch.map`; it does not verify the current import flow.
+Existing Apollo maps and saves are reused in place under `halo/data` and
+`halo/save`. Each browser and domain has a separate cache. Browser storage
+can be cleared or evicted.
+
+## Default browser room
+
+The launcher automatically joins the public browser room **FQLX01** on a
+first visit. Share
+to enter that room directly. The mirror uses the same room, so players on
+both domains can meet. An explicit room link or a previously chosen room
+takes precedence; choosing **Leave** keeps the browser out across reloads
+until the player opens a room link or chooses **Join default room**.
+
+After the UI and Beaver Creek maps are ready, the launcher starts multiplayer automatically.
+The first ready participant hosts **Beaver Creek Slayer**; later participants
+join that host, including while the match is running. Only players preparing
+to launch participate in host selection, so an idle tab or a disc import does
+not become the host. The public room is not a persistent game server.
+
+The source migration path preserves the loaded match when its host leaves.
+It pauses gameplay, elects a survivor with a verified checkpoint, and reconnects
+players into their existing slots under the replacement host. Scores, positions,
+inventory and the timer are retained. Recovery failure leaves the match paused;
+it never starts a new match automatically. All participants need the matching
+new launcher and runtime (native network version 5); apply the offered update
+on every participating browser.
+
+A brief connection loss has a 10-second grace period, and silent channels are
+detected after 25 seconds, followed by election and reconnecting. Authority-only
+state may resume from the latest 15-tick checkpoint. Returning hosts yield to
+the newer room epoch. Public signaling does not provide distributed consensus;
+partitions can still create divergent continuations of a match.
+
+Hold **Tab** (or **F1**) to show the scoreboard. **(HOST)** identifies the
+current host, and **Ping** shows each player's round-trip latency to that host
+in milliseconds. Missing or stale samples show `--`; host migration clears
+the previous measurements. Blank or whitespace-only player names receive a
+unique random name, including players joining a match in progress.
+
+The page shows connection and loading progress. A browser may require a tap
+to enable sound and pointer capture, but no System Link menu navigation is
+needed. Manual browser System Link also allows a single player to start a
+non-team game with distributed networking; the game stays open for later
+players. Team readiness and the two-machine requirement for lockstep remain.
+**Main menu** opts out of quick play; `?menu=1` opens the normal
+launcher and asks for a disc image if the full map set is missing. Leaving a
+quick-play match for the menu also checks for those maps, so other scenarios
+cannot be selected before their data is available. A failed attempt shows an error instead of repeatedly
+restarting the game. Private room links use the same quick-play flow.
+
+Use the in-game **Room** button to open the room controls, then choose
+**New room** or enter another code. Switching rooms restarts Halo into the
+selected room using cached maps. **Share link** includes the currently
+joined room; a `?room=FQLX01` link always selects FQLX01. **Leave** closes
+the current game and returns to the launcher without automatically rejoining.
+
+Browser rooms use the existing public signaling and STUN services. Some
+networks still require a TURN relay in Settings; no TURN account or native
+relay is configured by the default room. The room code is public, so use
+**New room** for a separate group.
+
+Native desktop invitations require a separately hosted WSS relay. The
+public sites currently leave that endpoint unset. Chrome has joined a
+native match through a local test relay, but the public link cannot yet
+join native hosts. See [BROWSER_NATIVE_INVITES.md](BROWSER_NATIVE_INVITES.md)
+for the protocol, deployment requirements and validation limits.
+
+## Build and package
+
+Use Emscripten 6.0.10, Python 3 and Ninja:
+
+```sh
+source build/emsdk/emsdk_env.sh
+python3 configure.py --release
+ninja web
+python3 tools/build_multiplayer_pages.py
+```
+
+The output is `dist/browser-multiplayer`. It contains the runtime, launcher
+and service worker, with no ISO or maps. `deployment.json` records the
+source commit, build state and each packaged file's SHA-256. Commit source
+before packaging. Add `--relay wss://YOUR-RELAY-HOST/join` only when that
+relay is ready; an unset relay leaves desktop joining unavailable.
+Use `--default-room CODE` to choose a different public room or
+`--default-room ''` to disable automatic room entry.
+
+The launcher copies maps from each player's own disc image into browser
+storage. The public package and multiplayer relay do not serve game assets.
+Game assets retain their own copyrights and are not covered by the source-code license.
+
+## Publish
+
+Publish the generated package to `fqlx/halo-ce-universal`, branch
+`fqlx/pages-static`, and to the `halo-ce-universal/` subdirectory of
+`fqlx/abwburns-site`, branch `fqlx/site`. Keep both branch histories.
+Preserve the personal site's root files and `CNAME`. The account repository
+`fqlx/fqlx.github.io` has no custom domain, keeping the game URL on GitHub.
+
+HTTPS, service workers, WebGL 2, browser file storage and shared memory
+must be available. The service worker supplies cross-origin isolation
+headers that GitHub Pages cannot configure directly, and caches each
+runtime version together. Reload after deployment and apply an offered
+update. Do not clear website data just to update the runtime, since that
+also removes imported maps and saves.
+
+Use the direct play URL when sharing. An iframe's parent also needs
+cross-origin isolation and appropriate permissions; embedding the link
+in an arbitrary page cannot enable threads by itself.
+
+For a local static-host check without special server headers:
+
+```sh
+python3 -m http.server 8778 --bind 127.0.0.1 --directory dist/browser-multiplayer
+```
+
+Open . The service worker establishes isolation and
+reloads once, then the launcher checks browser features and map storage.
+Run `node tools/test_source_cache.mjs` and
+`node --test tools/tests/*.test.cjs` for cache and invitation checks.
+
+## Previous Apollo runner
+
+`tools/build_pages.py` and [BROWSER_LOCAL.md](BROWSER_LOCAL.md) remain
+available for reproducing the older hash-pinned Apollo runtime and its
+rendering wrappers. That packager outputs `dist/github-pages`; it is
+separate from the source-built runtime now hosted on the public sites.
+It packages no game data and has no automatic map download option.
+Apollo performance measurements do not establish performance of the newer
+source-built engine, and its original runtime has no multiplayer sockets.
diff --git a/README.md b/README.md
index 2db745cc1f..ec5c167afc 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Halo: Combat Evolved for Linux, Windows and Android
+# Halo: Combat Evolved for Linux, Windows, Android, the web and iOS
[](https://discord.gg/9gqcHyr5km)
@@ -12,6 +12,11 @@ Windows and Android. The decompilation is of the Xbox build 2342
The port starts from the decompilation of [bnunu/halo-1](https://github.com/bnunu/halo-1).
That project is a fork of [punpckhdq/halo](https://github.com/punpckhdq/halo).
+Native iPhone/iPad ARM64 support and browser-room integration are in draft.
+See [iOS build instructions](port/ios/README.md) and the
+[validation record](port/ios/VALIDATION.md). Full native/browser gameplay has
+not yet been validated on this branch.
+
## Download
GitHub Actions builds the game for each commit. These links download the
@@ -38,7 +43,7 @@ an older build from that page.
## Game data
-The port does not include the game data. Download an Xbox disc image
+The port does not include the game data. Use your own Xbox disc image
(`.xiso` or `.iso`) of Halo: Combat Evolved. All versions of the game
operate. The maps of the European (PAL) version were made for a slower
console. The port changes them to play as the North American (NTSC) maps do,
@@ -61,6 +66,11 @@ Each platform has its own instructions:
| Linux (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/linux/README.md](port/linux/README.md) |
| Windows (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/windows/README.md](port/windows/README.md) |
| Android (arm64 app, OpenGL ES 3, SDL3) | [port/android/README.md](port/android/README.md) |
+| iOS (ARM64 iPhone/iPad, OpenGL ES 3, SDL3; draft browser crossplay) | [port/ios/README.md](port/ios/README.md) |
+| Browser (local runner for the published Apollo WASM beta) | [BROWSER_LOCAL.md](BROWSER_LOCAL.md) |
+| Browser (static hosting / GitHub Pages) | [GITHUB_PAGES.md](GITHUB_PAGES.md) |
+| Browser invitations for native multiplayer (relay preview) | [BROWSER_NATIVE_INVITES.md](BROWSER_NATIVE_INVITES.md) |
+| Web: iPhone, iPad and browsers (WebAssembly, WebGL 2, installable web app) | [port/web/README.md](port/web/README.md) |
The Linux README also gives the controls, the settings and the multiplayer
functions. These are almost the same on all platforms.
@@ -95,6 +105,7 @@ To build the game:
| `ninja linux` | `build/linux/halo` |
| `ninja windows` (on Windows) | `build/windows/halo.exe` and `SDL3.dll` |
| `ninja android_apk` | `port/android/app/build/outputs/apk/debug/app-debug.apk` |
+| `ninja web` (with Emscripten) | `build/web/site` |
If you enter `ninja` without a target, ninja builds the game for the
computer that you use.
diff --git a/configure.py b/configure.py
index 3bff193b0b..77704a85d9 100644
--- a/configure.py
+++ b/configure.py
@@ -11,8 +11,10 @@
from types import SimpleNamespace
from tools import ninja_syntax
+from tools.ios_guest_build import generate_ios_guest_build, ios_guest_configure_inputs
from tools.android_build import android_configure_inputs, generate_android_build
from tools.linux_build import generate_linux_build, linux_configure_inputs
+from tools.web_build import generate_web_build, web_configure_inputs
from tools.windows_build import generate_windows_build, windows_configure_inputs
# arguments
@@ -73,11 +75,20 @@
type=str,
help="clang with the arm64_32 target for the Android guest (default: clang)",
)
+parser.add_argument(
+ "--web-emcc",
+ type=str,
+ help="Emscripten's emcc for `ninja web` (default: emcc on the PATH, or ~/emsdk)",
+)
+parser.add_argument('--ios', action='store_true', help='generate native iOS ARM guest rules')
+parser.add_argument('--ios-llvm', default='/opt/homebrew/opt/llvm')
+parser.add_argument('--ios-lld', default='/opt/homebrew/opt/lld/bin/ld.lld')
args = parser.parse_args()
# the settings the builds read
sln = SimpleNamespace(
build_dir=Path("build"),
+ ios=args.ios, ios_llvm=args.ios_llvm, ios_lld=args.ios_lld,
linux_cc=args.linux_cc,
compiler_launcher=args.compiler_launcher,
port_release=args.release,
@@ -87,6 +98,7 @@
port_pgo_profile=args.pgo_profile,
android_ndk=args.android_ndk,
android_guest_cc=args.android_guest_cc,
+ web_emcc=args.web_emcc,
)
@@ -112,6 +124,8 @@ def is_windows() -> bool:
generate_linux_build(n, sln)
generate_android_build(n, sln)
generate_windows_build(n, sln)
+generate_ios_guest_build(n, sln)
+generate_web_build(n, sln)
n.comment("Reconfigure on change")
n.rule(
@@ -129,6 +143,8 @@ def is_windows() -> bool:
*linux_configure_inputs(),
*android_configure_inputs(),
*windows_configure_inputs(),
+ *ios_guest_configure_inputs(),
+ *web_configure_inputs(),
],
)
n.newline()
diff --git a/port/apple/DISTRIBUTION.md b/port/apple/DISTRIBUTION.md
new file mode 100644
index 0000000000..52b685adc5
--- /dev/null
+++ b/port/apple/DISTRIBUTION.md
@@ -0,0 +1,49 @@
+# Distribution status
+
+The Apple applications are unofficial community ports, not Microsoft, Bungie,
+or Halo Studios products. This document records packaging decisions, not a
+legal opinion or permission to distribute the reconstructed game code.
+
+## What the packages contain
+
+Apple builds embed the compiled native runtime and reconstructed game code,
+SDL, ANGLE, bundled room-transport JavaScript, open-source dependency notices,
+an original abstract ringworld icon, and the upstream XML menus and redrawn
+menu graphics (`port/assets/menus`). They do not embed a user's disc image, maps,
+saves, or the repository's replacement HUD/title/font assets. Original game images,
+audio and fonts are loaded from the user's imported disc image/map cache.
+
+First launch opens a native Files/Finder picker. Import copies the selected
+ISO/XISO into private storage, hashes it with SHA-256 while copying, reads the
+copy back to verify the same digest, and checks XDVDFS structure and the
+complete supported Xbox Halo map set. It publishes the generation atomically
+only after these steps succeed. A cancelled or rejected import leaves the
+previous generation and saves intact. Subsequent starts use the retained copy's
+extracted map cache, checking the image's size and map headers; they do not
+re-hash several gigabytes on every launch. The stored SHA-256 detects copy
+errors; it does not prove ownership or authenticate a known retail pressing.
+A full disc requires space for the image plus approximately 1.9 GB of maps.
+
+On iOS this storage is inside the app container. On macOS it is in
+`~/Library/Containers/org.haloce.macos/Data/Library/Application Support/org.haloce.macos/`
+in the sandboxed distribution build (unentitled local builds use the normal
+Application Support directory). Saves and settings live
+outside the replaceable game generation. Existing installations that only
+have extracted maps must select their image once; existing maps/saves are kept.
+
+## Rights and distribution channels
+
+The upstream project labels its contributions CC0, and dependency licenses
+must remain included. That label does not establish the contributor's rights
+to reconstructed proprietary code, original assets, or trademarks. Requiring
+a user-supplied game image does not settle those questions. Microsoft's
+[Game Content Usage Rules](https://www.xbox.com/en-US/developers/rules) are not
+a clearance for this decompiled port: they expressly restrict reverse
+engineering to create covered items. Obtain a qualified IP review or relevant
+rights-holder permission before treating a public release as legally cleared.
+
+Mac distribution should use Developer ID signing and Apple notarization;
+[Apple's distribution guidance](https://developer.apple.com/developer-id/)
+explains the separate Gatekeeper requirements. Signing/notarization is not
+copyright clearance. An IPA still needs an appropriate Apple-authorized
+signing/distribution method; an unsigned IPA is not universally installable.
diff --git a/port/apple/RingArtwork.png b/port/apple/RingArtwork.png
new file mode 100644
index 0000000000..1856d7b728
Binary files /dev/null and b/port/apple/RingArtwork.png differ
diff --git a/port/apple/game_store.h b/port/apple/game_store.h
new file mode 100644
index 0000000000..cebc8acada
--- /dev/null
+++ b/port/apple/game_store.h
@@ -0,0 +1,7 @@
+#import
+#include "xiso.h"
+/* The root contains an atomically published generation holding the private
+ image, integrity manifest and extracted map cache. Saves live outside it. */
+NSString *halo_game_store_current(NSString *root);
+BOOL halo_game_store_import(NSURL *image, NSString *root, xiso_progress_proc progress,
+ void *context, NSString **failure);
diff --git a/port/apple/game_store.m b/port/apple/game_store.m
new file mode 100644
index 0000000000..be8d74d1fb
--- /dev/null
+++ b/port/apple/game_store.m
@@ -0,0 +1,86 @@
+#import "game_store.h"
+#import
+#include
+#include
+#include
+
+static NSString *digest_file(NSString *path, FILE *copy, xiso_progress_proc progress,
+ void *context, const char *phase, NSString **failure) {
+ FILE *input=fopen(path.fileSystemRepresentation,"rb");
+ if(!input){*failure=@"Could not read the disc image.";return nil;}
+ struct stat before,after;
+ if(fstat(fileno(input),&before) || !S_ISREG(before.st_mode) || before.st_size<=0) {
+ fclose(input);*failure=@"Choose a non-empty disc image file.";return nil;
+ }
+ CC_SHA256_CTX hash;CC_SHA256_Init(&hash);
+ unsigned char *buffer=malloc(1024*1024);uint64_t done=0;BOOL ok=buffer!=NULL;
+ while(ok) {
+ if(progress && !progress(context,phase,done,before.st_size)){*failure=@"Import cancelled.";ok=NO;break;}
+ size_t count=fread(buffer,1,1024*1024,input);
+ if(!count){if(ferror(input))ok=NO;break;}
+ if(copy && fwrite(buffer,1,count,copy)!=count){ok=NO;break;}
+ CC_SHA256_Update(&hash,buffer,(CC_LONG)count);done+=count;
+ }
+ if(fstat(fileno(input),&after) || before.st_size!=after.st_size || done!=(uint64_t)before.st_size ||
+ before.st_mtimespec.tv_sec!=after.st_mtimespec.tv_sec || before.st_mtimespec.tv_nsec!=after.st_mtimespec.tv_nsec)ok=NO;
+ free(buffer);fclose(input);
+ if(!ok){if(!*failure)*failure=@"Could not copy the complete image. Check available storage and try again.";return nil;}
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];CC_SHA256_Final(digest,&hash);
+ NSMutableString *result=[NSMutableString new];for(int i=0;i
+
-
+
@@ -39,7 +40,7 @@
-
+
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 0000000000..271955a7e6
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png
new file mode 100644
index 0000000000..be59ef3c35
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png
new file mode 100644
index 0000000000..3b62de372b
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png
new file mode 100644
index 0000000000..261cff7400
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png
new file mode 100644
index 0000000000..a1d742e068
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png
new file mode 100644
index 0000000000..12ecbf0106
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png
new file mode 100644
index 0000000000..28de9f097f
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png
new file mode 100644
index 0000000000..d9f926daf5
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png
new file mode 100644
index 0000000000..aef098c89a
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png
new file mode 100644
index 0000000000..14a54b13d5
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png
new file mode 100644
index 0000000000..9f4ec9a794
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png
new file mode 100644
index 0000000000..0d38f99280
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png
new file mode 100644
index 0000000000..51711067f3
Binary files /dev/null and b/port/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png differ
diff --git a/port/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/port/ios/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000000..f938fc55c0
--- /dev/null
+++ b/port/ios/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images": [
+ {
+ "idiom": "iphone",
+ "size": "20x20",
+ "scale": "2x",
+ "filename": "AppIcon-40.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "20x20",
+ "scale": "3x",
+ "filename": "AppIcon-60.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "29x29",
+ "scale": "2x",
+ "filename": "AppIcon-58.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "29x29",
+ "scale": "3x",
+ "filename": "AppIcon-87.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "40x40",
+ "scale": "2x",
+ "filename": "AppIcon-80.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "40x40",
+ "scale": "3x",
+ "filename": "AppIcon-120.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "60x60",
+ "scale": "2x",
+ "filename": "AppIcon-120.png"
+ },
+ {
+ "idiom": "iphone",
+ "size": "60x60",
+ "scale": "3x",
+ "filename": "AppIcon-180.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "20x20",
+ "scale": "1x",
+ "filename": "AppIcon-20.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "20x20",
+ "scale": "2x",
+ "filename": "AppIcon-40.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "29x29",
+ "scale": "1x",
+ "filename": "AppIcon-29.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "29x29",
+ "scale": "2x",
+ "filename": "AppIcon-58.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "40x40",
+ "scale": "1x",
+ "filename": "AppIcon-40.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "40x40",
+ "scale": "2x",
+ "filename": "AppIcon-80.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "76x76",
+ "scale": "1x",
+ "filename": "AppIcon-76.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "76x76",
+ "scale": "2x",
+ "filename": "AppIcon-152.png"
+ },
+ {
+ "idiom": "ipad",
+ "size": "83.5x83.5",
+ "scale": "2x",
+ "filename": "AppIcon-167.png"
+ },
+ {
+ "idiom": "ios-marketing",
+ "size": "1024x1024",
+ "scale": "1x",
+ "filename": "AppIcon-1024.png"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/port/ios/Assets.xcassets/Contents.json b/port/ios/Assets.xcassets/Contents.json
new file mode 100644
index 0000000000..74d6a722cf
--- /dev/null
+++ b/port/ios/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/port/ios/Assets.xcassets/RingArtwork.imageset/Contents.json b/port/ios/Assets.xcassets/RingArtwork.imageset/Contents.json
new file mode 100644
index 0000000000..30e0e8aa5f
--- /dev/null
+++ b/port/ios/Assets.xcassets/RingArtwork.imageset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"filename":"RingArtwork.png","idiom":"universal"}],"info":{"author":"xcode","version":1}}
diff --git a/port/ios/Assets.xcassets/RingArtwork.imageset/RingArtwork.png b/port/ios/Assets.xcassets/RingArtwork.imageset/RingArtwork.png
new file mode 100644
index 0000000000..ea053cd70a
Binary files /dev/null and b/port/ios/Assets.xcassets/RingArtwork.imageset/RingArtwork.png differ
diff --git a/port/ios/CMakeLists.txt b/port/ios/CMakeLists.txt
new file mode 100644
index 0000000000..fa29886244
--- /dev/null
+++ b/port/ios/CMakeLists.txt
@@ -0,0 +1,85 @@
+# Build the iPhone/iPad host and SDL around the statically compiled guest image.
+cmake_minimum_required(VERSION 3.24)
+project(HaloCE LANGUAGES C CXX OBJC ASM)
+if(CMAKE_SYSTEM_NAME STREQUAL "tvOS")
+ message(FATAL_ERROR "Browser-room integration currently targets iOS, not tvOS")
+endif()
+set(HALO_BUNDLE_IDENTIFIER "org.haloce.ios" CACHE STRING "App bundle identifier")
+get_filename_component(ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
+set(SDL_SHARED OFF CACHE BOOL "" FORCE)
+set(SDL_STATIC ON CACHE BOOL "" FORCE)
+set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
+set(SDL_TESTS OFF CACHE BOOL "" FORCE)
+set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE)
+add_subdirectory("${ROOT}/build/third_party/SDL3" sdl3 EXCLUDE_FROM_ALL)
+set(HALO_PLATFORM_SOURCES Assets.xcassets host/host_import.m host/host_orientation.m host/host_touch.m)
+set(HALO_INFO_PLIST Info.plist)
+set(HALO_DEVICE_FAMILY "1,2")
+add_executable(HaloCE MACOSX_BUNDLE
+ ${HALO_PLATFORM_SOURCES} "${ROOT}/port/runtime/xiso.c" "${ROOT}/port/apple/game_store.m"
+ host/host_main.m host/host_memory.c host/host_loader.c host/host_syscall.c host/host_gl.c host/host_debug.m host/host_metalfx.m host/host_graphics.m host/host_shader_cache.m host/guest_call.S
+ host/host_thread.c host/host_sdl.c network/host_room.m network/room_bridge.c
+ "${ROOT}/port/ios/network/host_web_net.c" "${ROOT}/port/ios/network/host_net.c" "${ROOT}/port/ios/network/host_browser.m"
+ "${ROOT}/port/linux/src/posix_files.c" host/posix_net.c
+ "${ROOT}/build/ios/host/bridges.c" "${ROOT}/build/ios/embedded/guest_image.S")
+target_include_directories(HaloCE PRIVATE host network "${ROOT}/port/web/src" "${ROOT}/port/runtime" "${ROOT}/port/runtime/include"
+ "${ROOT}/port/runtime/guest/runtime" "${ROOT}/port/linux/src" "${ROOT}/build/ios/host"
+ "${ROOT}/build/ios/gl_include" "${ROOT}/build/ios/embedded")
+target_compile_definitions(HaloCE PRIVATE HALO_IOS=1 HALO_IOS_BROWSER_HOST=1 st_mtim=st_mtimespec st_atim=st_atimespec st_ctim=st_ctimespec)
+target_compile_options(HaloCE PRIVATE -Wno-deprecated-declarations)
+target_compile_options(HaloCE PRIVATE "$<$:-fobjc-arc>")
+target_link_libraries(HaloCE PRIVATE SDL3::SDL3-static "-framework WebKit" "-framework QuartzCore" "-framework Foundation" "-framework UIKit" "-framework OpenGLES")
+target_link_libraries(HaloCE PRIVATE "-framework UniformTypeIdentifiers" "-framework Metal" "-framework CoreVideo")
+set_source_files_properties(Assets.xcassets PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
+set_target_properties(HaloCE PROPERTIES XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon")
+file(GLOB HALO_LICENSES "${ROOT}/build/ios/licenses/*.txt")
+target_sources(HaloCE PRIVATE ${HALO_LICENSES})
+set_source_files_properties(${HALO_LICENSES} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses)
+set_target_properties(HaloCE PROPERTIES MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/${HALO_INFO_PLIST}"
+ XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES
+ XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${HALO_BUNDLE_IDENTIFIER}"
+ XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "${HALO_DEVICE_FAMILY}"
+ XCODE_ATTRIBUTE_CODE_SIGN_STYLE Automatic)
+if(HALO_DEVELOPMENT_TEAM)
+ set_target_properties(HaloCE PROPERTIES XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${HALO_DEVELOPMENT_TEAM}")
+endif()
+
+# Ship the exact browser room protocol alongside its native packet adapter.
+set(HALO_ROOM_SCRIPTS "${ROOT}/port/web/site/net.js" "${CMAKE_CURRENT_SOURCE_DIR}/network/room.js")
+target_sources(HaloCE PRIVATE ${HALO_ROOM_SCRIPTS})
+set_source_files_properties(${HALO_ROOM_SCRIPTS} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Network)
+
+if(NOT CMAKE_OSX_SYSROOT MATCHES "[Ss]imulator")
+ target_link_libraries(HaloCE PRIVATE "-framework MetalFX")
+endif()
+
+execute_process(COMMAND git describe --always --dirty WORKING_DIRECTORY "${ROOT}"
+ OUTPUT_VARIABLE HALO_BUILD_REVISION OUTPUT_STRIP_TRAILING_WHITESPACE)
+target_compile_definitions(HaloCE PRIVATE HALO_BUILD_REVISION="${HALO_BUILD_REVISION}")
+
+# Embed without linking: explicit symbol dispatch keeps Apple's GLES symbols
+# separate from ANGLE's identically named entry points.
+set(ANGLE_SLICE ios-arm64)
+if(CMAKE_OSX_SYSROOT MATCHES "[Ss]imulator")
+ set(ANGLE_SLICE ios-arm64-simulator)
+endif()
+set(ANGLE_ROOT "${ROOT}/build/third_party/angle/dist")
+set_target_properties(HaloCE PROPERTIES
+ XCODE_EMBED_FRAMEWORKS "${ANGLE_ROOT}/EGL.xcframework/${ANGLE_SLICE}/libEGL.framework;${ANGLE_ROOT}/GLESv2.xcframework/${ANGLE_SLICE}/libGLESv2.framework"
+ XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY YES
+ XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY YES
+ XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/Frameworks"
+ XCODE_ATTRIBUTE_COPY_PHASE_STRIP NO)
+target_link_libraries(HaloCE PRIVATE "-framework IOSurface")
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/apple/DISTRIBUTION.md")
+set_source_files_properties("${ROOT}/port/apple/DISTRIBUTION.md" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses)
+
+target_sources(HaloCE PRIVATE LaunchScreen.storyboard)
+set_source_files_properties(LaunchScreen.storyboard PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/third_party/monocypher/LICENCE.md")
+set_source_files_properties("${ROOT}/port/third_party/monocypher/LICENCE.md" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses/Monocypher)
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/third_party/expat/COPYING")
+set_source_files_properties("${ROOT}/port/third_party/expat/COPYING" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses/Expat)
diff --git a/port/ios/ICON.md b/port/ios/ICON.md
new file mode 100644
index 0000000000..99d6c5d28f
--- /dev/null
+++ b/port/ios/ICON.md
@@ -0,0 +1,15 @@
+# Abstract ringworld icon
+
+Original artwork generated with the imagegen skill from the user's ringworld
+sketch on 2026-10-03: a blue waterway broad in the foreground, narrowing up the
+inner surface of an immense ring, framed by dark mountain silhouettes.
+No game screenshot, character, or franchise lettering was used as input.
+
+The master is `port/apple/RingArtwork.png`. Generate iOS icon sizes and the Mac icon with:
+
+```
+swift tools/apple_icon.swift port/ios/Assets.xcassets/AppIcon.appiconset port/macos/Community.icns
+```
+
+The `RingArtwork` image set displays this artwork on the import screen and iOS launch screen. The
+macOS `Community.icns` contains resized versions of the same artwork.
diff --git a/port/ios/Icon-Artwork.png b/port/ios/Icon-Artwork.png
new file mode 100644
index 0000000000..6b7b378366
Binary files /dev/null and b/port/ios/Icon-Artwork.png differ
diff --git a/port/ios/Info.plist b/port/ios/Info.plist
new file mode 100644
index 0000000000..540b108b5a
--- /dev/null
+++ b/port/ios/Info.plist
@@ -0,0 +1,61 @@
+
+
+
+
+ CFBundleDisplayName
+ Halo
+ CFBundleExecutable
+ HaloCE
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleName
+ Halo: Combat Evolved
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.10
+ CFBundleVersion
+ 14
+ LSRequiresIPhoneOS
+
+ LSSupportsOpeningDocumentsInPlace
+
+ NSLocalNetworkUsageDescription
+ Connect to other Halo players on your local network.
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ SDLSceneConfiguration
+ UISceneDelegateClassName
+ SDLUIKitSceneDelegate
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UIFileSharingEnabled
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIRequiresFullScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/port/ios/LaunchScreen.storyboard b/port/ios/LaunchScreen.storyboard
new file mode 100644
index 0000000000..3486684fb4
--- /dev/null
+++ b/port/ios/LaunchScreen.storyboard
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/port/ios/README.md b/port/ios/README.md
new file mode 100644
index 0000000000..7f76ae654a
--- /dev/null
+++ b/port/ios/README.md
@@ -0,0 +1,444 @@
+# Build and install Halo: CE on iPhone and iPad
+
+This directory builds a native ARM64 iOS app around the existing game's ILP32
+runtime. It uses SDL3, OpenGL ES 3, UIKit touch controls, and the user's original
+Xbox map files. The game files are separate from the application and are never
+included in source control.
+
+The Home Screen name is **Halo: CE**. The portable ILP32 runtime lives in
+`port/runtime`; UIKit, Darwin, touch, audio, and the native loader live here.
+The existing Android, Linux, Windows and web build targets are retained.
+
+The icon is original abstract ringworld artwork; see [icon notes](ICON.md). Apple builds
+exclude the bundled replacement HUD/title/font assets and require a user-owned
+disc image. See [distribution status](../apple/DISTRIBUTION.md).
+
+## Build
+
+Use an Apple Silicon Mac with Xcode, its iOS SDK, Python 3, CMake, Ninja, LLVM
+and LLD. The validated toolchain is Xcode 27 and Homebrew LLVM/LLD 23.1.2.
+The deployment target is iOS 16, but older OS versions have not been validated.
+Install dependencies using `brew install cmake ninja llvm lld sdl3 pkgconf`.
+Open Xcode once to accept its license and install the iOS platform. Ensure
+`xcode-select -p` points into full Xcode, not only Command Line Tools.
+For signed device builds, add your Apple account in Xcode Settings > Accounts.
+Your provisioning profile must cover the device and chosen bundle identifier.
+See [Apple's device setup guide](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices).
+
+From the repository root:
+
+```sh
+# Native ARM64 simulator app (no signing).
+python3 tools/ios_build.py --simulator
+
+# Device app, signed with your Apple development team.
+python3 tools/ios_build.py --team YOUR_TEAM_ID --bundle-id com.yourname.haloce
+
+# Unsigned device IPA (no Apple account needed to build).
+python3 tools/ios_build.py --unsigned --ipa dist/Halo-CE-iOS-unsigned.ipa
+```
+
+The script fetches pinned Khronos headers, SDL release-3.4.16 and musl 1.2.5,
+compiles the guest, embeds it into signed application text, and builds the host.
+Specify `--llvm` and `--lld` for non-default toolchain locations.
+Game assertions remain enabled. An Xcode `Release` host configuration does not
+disable the game's assertions. PGO and LTO are disabled for this initial port.
+
+Outputs:
+
+- `build/ios/app-device/Release-iphoneos/HaloCE.app`
+- `build/ios/app-simulator/Release-iphonesimulator/HaloCE.app`
+- `build/ios/app-unsigned/Release-iphoneos/HaloCE.app`
+- `dist/Halo-CE-iOS-unsigned.ipa` and its `.sha256` checksum when requested
+
+The default bundle identifier is `org.haloce.ios`. Set `--bundle-id` to one
+covered by your signing profile. `--ipa PATH` can also package a signed build;
+only unsigned builds are suitable for this project's public release workflow.
+Code signing and provisioning must succeed
+before installation. No jailbreak, JIT entitlement, or writable executable
+memory is used.
+
+## Simulator smoke tests
+
+After installing the simulator app and importing an ISO through the app,
+these explicit launch settings bypass the room chooser for repeatable tests:
+
+```sh
+xcrun simctl launch SIMULATOR_UDID org.haloce.ios
+SIMCTL_CHILD_HALO_IOS_TEST_ROOM=YOUR_TEST_ROOM xcrun simctl launch SIMULATOR_UDID org.haloce.ios
+```
+
+Terminate the app between launches. These settings apply only to simulator
+builds; normal launches open the game menus. `HALO_IOS_TEST_ROOM_UI=1`
+opens the room form with its keyboard for simulator layout inspection. Use a unique room code
+and the same code in a browser build from the pinned PR #12 source.
+
+## Install and add game data
+
+The cache validator accepts these exact Xbox v5 cache builds on iOS:
+
+- PAL: `01.01.14.2342`
+- NTSC-US: `01.10.12.2276` (experimental compatibility)
+
+### Import on the device
+
+1. Sign/install the IPA with your own account, then open **Halo: CE**.
+2. Tap **Choose Halo ISO** and select your `.iso` or `.xiso` in Files (On My
+ iPhone/iPad, iCloud Drive, or another Files provider). Compressed ZIP/7z
+ archives and PC/MCC disc images are not supported.
+3. The app copies the image into a private generation, verifies the copy with
+ SHA-256 readback, and validates the Xbox filesystem, cache version/build,
+ and complete campaign map set during extraction. Cancel safely stops it.
+ Once finished, the game starts automatically.
+
+The image is opened through the system document picker with coordinated,
+security-scoped access. A cloud image may need to download before extraction.
+Leave enough local storage for the retained image plus approximately 1.9 GB
+of extracted maps, and any separate source copy already on the phone.
+Nothing is fetched from a game-download service, and the source image is never
+modified or deleted. Subsequent launches use the private image/map generation
+without accessing the original Files location.
+
+You can also copy **one** `.iso` or `.xiso` directly into Halo: CE's Documents
+folder using Finder's Files tab or Files > On My iPhone/iPad > Halo: CE, then
+launch the app. It detects and imports that image when game data is missing.
+If several images are present, choose one with the picker. After a successful
+import, the original loose source file can be removed. Keep the retained
+`game-UUID/disc.iso` and its map cache; deleting these requires re-import.
+
+Imports run in a private staging directory. Invalid or cancelled imports do
+not replace the current game generation. Owned incomplete imports are cleaned
+up on next launch. Existing committed generations are preserved. Saves
+and profiles stay in `Documents/save` throughout.
+
+### Install a source build
+
+List devices with `xcrun devicectl list devices`. Enable Developer Mode when
+iOS requests it, pair/trust the Mac, and keep the device unlocked during
+installation. Replace `DEVICE_UDID` and the example bundle identifier with
+your device and the ID used for signing.
+
+```sh
+xcrun devicectl device install app --device DEVICE_UDID \
+ build/ios/app-device/Release-iphoneos/HaloCE.app
+xcrun devicectl device process launch --device DEVICE_UDID com.yourname.haloce
+```
+
+Downloaded unsigned IPAs must first be signed with your own Apple account.
+Use [`tools/ios_resign.sh`](../../tools/ios_resign.sh) with your local signing
+identity and device provisioning profile; see the [re-signing and installation
+guide](RESIGNING.md) for the complete setup and commands.
+For packaged-IPA signing instructions, see the official
+[AltStore Classic setup guide](https://faq.altstore.io/) or
+[Sideloadly FAQ](https://sideloadly.io/faq). These signing tools have not been
+validated as part of this port; direct Xcode builds have been tested on iPhone 13 Pro. Follow your signing tool's refresh instructions before
+the provisioning profile expires. Keep the same account and bundle identifier
+when updating to preserve the app's data.
+
+### Optional manual extraction
+
+The Mac helper remains available for inspecting your XISO or preparing maps
+manually. It preserves original bytes and records build IDs, sizes and SHA-256
+hashes. Existing output files are never replaced.
+
+```sh
+python3 tools/ios_extract_assets.py '/path/to/Halo.xiso.iso'
+python3 tools/ios_extract_assets.py '/path/to/Halo.xiso.iso' --output assets
+```
+
+Manual map extraction is an inspection tool; maps alone no longer satisfy
+first-launch setup. Import the image through the app to retain its verified
+private copy. `config.toml` and `ios-runtime.log` stay in Documents; the game's
+`debug.txt` is in the active `game-UUID` folder. Back up `Documents/save`
+before uninstalling or changing bundle IDs.
+
+## Controls
+
+### Resolution
+
+Native physical display resolution is the default, with a Retina drawable and
+matching internal color/depth targets. The game retains its original logical
+layout coordinates, so the HUD and touch controls keep their size.
+
+For a lower GPU workload, edit the existing `[display]` section of
+`Documents/config.toml` and relaunch:
+
+```toml
+render_height = 0 # Native (default); 1080, 720, or 480 render fewer pixels
+screen_width = 0 # Fit the display; 640 selects original 4:3
+```
+
+The renderer preserves the selected aspect ratio and caps the render size to
+the drawable and GPU texture limit. Existing configuration files without
+`render_height` automatically use native resolution. Apple graphics diagnostics
+are available from the shake modal or Mac Settings window, as described below.
+
+### Touch and controller input
+
+The left stick moves and the right stick aims. The four arrows navigate menus.
+A selects/jumps; B returns/melees; X reloads/uses; Y changes weapons. Separate
+buttons provide fire, grenade, crouch, zoom, flashlight, grenade selection,
+and pause. Hold buttons for held actions. Touch controls hide automatically
+when a hardware controller connects and return when it disconnects. There is
+no hide/show toggle. The native Mac app never creates a touch overlay.
+Compatible Backbone, Xbox and PlayStation controllers use SDL's iOS gamepad
+backend. Pair Bluetooth controllers in iOS Settings > Bluetooth before or
+while the app runs; attach a compatible wired Backbone directly to the phone.
+See [Apple's controller pairing guide](https://support.apple.com/en-ie/111099).
+The USB-C Backbone requires a compatible USB-C device; it cannot plug directly
+into the Lightning iPhone 13 Pro.
+
+The first hardware controller shares player one with the on-screen controls.
+Touch controls and their visibility toggle hide when a gamepad connects and
+return when it disconnects. Player one's controller
+stays selected while connected, even if another controller is attached.
+
+| Action | Xbox / standard labels | PlayStation labels |
+| --- | --- | --- |
+| Select / jump | A | Cross |
+| Back / melee | B | Circle |
+| Reload / use | X | Square |
+| Switch weapon | Y | Triangle |
+| Fire / grenade | RT / LT | R2 / L2 |
+| Flashlight / switch grenade | RB / LB | R1 / L1 |
+| Crouch / zoom | Left / right stick click | L3 / R3 |
+| Pause | Menu / Start | Options |
+
+The user confirmed an Xbox Series X controller works on the iPhone.
+Backbone and PlayStation hardware validation remains pending;
+controller model-specific capabilities such as haptics are not promised.
+Developer console messages, frame counters, profiling text and the menu's build label are omitted
+from the game picture. Diagnostic log files remain available in Documents.
+
+The app supports landscape only on iPhone and iPad, including XISO import and
+the Files picker. On iPadOS 26 and later it also requests the interface
+orientation lock for the full-screen scene; portrait is excluded from the
+app's orientation masks. iPadOS controls windowed multitasking and may
+letterbox the landscape app when a full-screen orientation lock is unavailable.
+Public-server discovery and native UDP/KCP connections are enabled by default.
+Browser rooms remain an optional, separate transport described below. Bink intro videos remain unsupported by the upstream port.
+
+## Public server browser
+
+Open **Multiplayer → Join Game → Server Browser** to browse public native
+servers using upstream's in-game PC-style menus. Use the controller or the
+on-screen controls on iOS; macOS also supports mouse and keyboard. Select a
+server, inspect its game details, then join. The list shows maps, game types,
+player counts and connection status, with Refresh and Back buttons.
+Only network-version-11 signed listings are shown. A matching map from the
+user's imported image is required; discovery does not guarantee reachability.
+Keep the app in the foreground while connecting and playing.
+
+This integrates main through `193cbf59`, including the in-game server browser,
+focused-row outline, name validation, updated controls/settings and gameplay
+fixes. Discovery uses Ed25519 signatures, MQTT brokers and invite joining.
+Connections use the shared STUN, UDP tunnel and KCP implementation; automatic
+router port forwarding is not implemented on Apple. The old UIKit/AppKit list
+remains a diagnostic fallback for Xbox menus and automated native-join tests.
+PR #69's optional HTTP list and sorting enhancements are not included.
+
+Upstream commit `c9ee319a` added 28 bytes of PC game rules to the settings
+record without increasing network version 11. The relevant rule logic and
+record layout are included. Receiving older settings records supplies the
+original default rules. In browser-room mode, Apple emits the older settings
+layout; mixed-version checkpoint/migration compatibility has not been revalidated.
+
+## Browser multiplayer (experimental)
+
+This integration is based on [PR #12](https://github.com/cybersecurity/halo-ce-universal/pull/12)
+at `eaa82e6803f3d3e67c91d7f2fa2b15ee04e195f5`. Prefer a web client built
+from this branch. Network version **11** alone is insufficient to prove
+compatibility: upstream changed its game-settings layout within that version.
+The public web demos checked on 2026-10-04 use network version **6** and cannot
+join this app. Previous pinned-build crossplay/migration results predate the
+native-server update and are historical evidence, not validation of this build.
+Both players need matching original Xbox maps. Protocol compatibility does not
+establish compatibility between different game-data releases.
+
+Enable **Browser rooms (experimental)** in Settings (shake on iOS), then
+relaunch. Socket transport is fixed at startup so existing sockets cannot
+change underneath the game. Select **Multiplayer → Join Game → Server Browser** to open the native
+online-room form, then enter the web player's room code and choose **Join room**.
+With `display.menus = "xbox"`, the equivalent entry is **System Link**.
+The keyboard stays closed until the code field is tapped. The form scrolls,
+and Cancel/Join stay above the keyboard in landscape.
+The shared room controller elects a host and starts quick play, initially
+Beaver Creek Slayer. A native player can be elected host or join a web host.
+Cancel returns to the menus. After leaving a match, reopen the Server Browser to
+choose another room. Keep the app in the foreground.
+Background survival and recovery on physical devices remain unverified.
+
+The game, renderer, audio and memory runtime are compiled native ARM64 code.
+A retained `WKWebView` runs the **bundled, unchanged** `port/web/site/net.js`
+for encrypted MQTT signaling, WebRTC channels, election and host migration.
+There is no WASM game runtime in the iOS app. `network/room.js` exchanges bounded
+packet batches with `network/room_bridge.c`; `web_net.c` implements the same
+virtual sockets as the browser. Reliable streams and unreliable datagrams use
+the same wire framing. Game-thread control imports carry hold, reconnect,
+migration, cancellation, checkpoint reports and ping tables across the ILP32 ABI.
+
+This WebKit dependency avoids maintaining a second version of the room protocol.
+Packet batching and base64 copying add overhead that has not been profiled in
+a full match. If WebKit terminates, the game is held and the app must be restarted.
+The room view stays attached to the foreground scene below the game window.
+
+The shared transport uses public signaling/STUN services. Networks requiring
+TURN need the same TURN configuration as the web client. Optionally place this
+in `Documents/browser-room.json` (use your own server and credentials):
+
+```json
+{"turn":{"urls":["turn:relay.example.com:3478"],"username":"player","credential":"secret"}}
+```
+
+This file stays outside the bundle and repository. The native public-server
+protocol is a different transport; the app does not bridge native servers to WebRTC rooms.
+
+### Provenance and scope
+
+The signed ARM64 ILP32 runtime, memory layout, UIKit input, GLES renderer,
+XISO importer, and build tooling are imported/adapted from
+[NicholasDominici/halo-ce-ios](https://github.com/NicholasDominici/halo-ce-ios/tree/3f2c14101d3ae1c7f0c0a11993a43407fadbeb46),
+branch `ios-port`. Integration changes keep PR #12's current game/network source,
+use its musl math, preserve other platforms, and connect the Apple runtime to
+browser rooms. Apple distribution builds omit the embedded HUD/title/font
+replacements. This target builds iPhone/iPad apps; [port/macos](../macos/README.md)
+builds the native Mac app. Dedicated visionOS and tvOS products remain open.
+
+## How the port works
+
+The game relies on 32-bit pointers in its data structures. Apple's current
+ARM64 iOS binaries require their first 4 GB of address space to remain unmapped,
+so the game's 32-bit pointers are represented as offsets into an aligned
+native arena. This is native compiled code, not an Android emulator.
+
+`tools/ios_asm_convert.py` lifts compiled guest memory accesses and indirect
+branches into an aligned 4 GB arena using reserved registers x15 and x27.
+Guest pointer values and structure layouts stay 32-bit. PC-relative addresses
+are normalized back to guest offsets. A small signed assembly entry switches
+to an arena stack; host bridges translate pointers at the ABI boundary.
+
+Guest code is embedded in the app's signed `__TEXT` and aliased into the arena
+read/execute with `vm_remap`. Guest data is separately writable. The host
+translates Linux/musl calls to Darwin and handles Apple's 16 KB pages. Explicit
+Xbox 4 KB read-only regions protect only complete native pages, preserving
+writable neighboring buffers. Texture dirty tracking works at 16 KB granularity
+and serializes protection changes against concurrent streaming writers.
+
+The generated bridge resolves all guest imports before entering the game.
+The portable runtime and assembly conversion tools were derived from the
+upstream ARM64 port, with the iOS host and address model imported from NicholasDominici/halo-ce-ios.
+SDL/UIKit calls stay on the UI thread; guest workers and audio callbacks run on
+arena stacks with the same translation convention.
+The audio worker stages mixed PCM for the SDL callback to submit on its own
+thread. This avoids taking SDL's stream lock from a worker while the callback
+holds that lock and waits for the worker.
+
+## Regression checks
+
+```sh
+brew install sdl3 pkgconf # Native macOS SDL library for the audio regression.
+python3 tools/ios_test.py
+```
+
+This executes the translated guest on signed native pages and checks 32-bit
+structure layout, global/stack access, function pointers, atomic operations,
+and zero-extension of addresses. A separate stress test covers concurrent
+texture page tracking, fresh zeroed mappings, and the map inflater's writable
+4 KB tail beside a read-only buffer.
+The SDL regression checks 100 callbacks and 134,144 exact PCM samples,
+including reused guest stack buffers and a request larger than 64 KB.
+The native XISO importer is tested under AddressSanitizer and UBSan with
+synthetic disc images: exact byte preservation, supported builds, invalid
+headers, missing maps, truncated extents, cycles, unsafe names, duplicate
+files, cancellation/retry, existing files, and malformed directory mutations.
+
+These tests do not replace a device campaign test. See [the validation record](VALIDATION.md)
+for observed device/simulator behavior and remaining limitations.
+
+## Simulator
+
+Build with `--simulator`, then select and boot an ARM64 iPhone or iPad simulator
+in Xcode. With exactly one simulator booted:
+
+```sh
+xcrun simctl install booted build/ios/app-simulator/Release-iphonesimulator/HaloCE.app
+HALO_SIM_DATA=$(xcrun simctl get_app_container booted org.haloce.ios data)
+cp "/path/to/your/game.xiso" "$HALO_SIM_DATA/Documents/game.xiso"
+xcrun simctl launch booted org.haloce.ios
+```
+
+Use an explicit simulator ID instead of `booted` when more than one is running.
+The app imports the single loose image on first launch. Simulator graphics
+are slow; validate performance on a physical device.
+
+## Troubleshooting
+
+- **Signing fails:** check the Apple account in Xcode, team ID, unique bundle
+ identifier, device registration, and profile expiration. The script permits
+ Xcode provisioning updates; it never supplies somebody else's certificate.
+- **App fails to launch:** confirm it was signed for your device, trust the
+ developer if requested, and enable Developer Mode. An unsigned IPA will not launch.
+- **Menu never appears or maps fail validation:** check `Documents/game-UUID/maps/ui.map`,
+ confirm exact original Xbox build IDs above, and inspect `ios-runtime.log`
+ and the active generation’s `debug.txt`. Re-import if the retained image or
+ validated map cache is incomplete.
+- **Changing your bundle ID:** iOS treats this as a separate app/container.
+ Keep the same ID when updating and back up `Documents/save` before uninstalling.
+- **Audio issues:** share device/OS and output route along with the runtime
+ log. The regression suite checks PCM handoff; it does not test every route.
+
+## Graphics debug and shader reports
+
+Shake the iPhone/iPad to open the graphics settings modal. The dedicated
+AppKit/macOS target opens a separate **Settings…** window from the application
+menu (⌘,). Both retain the graphics options below; the Mac build uses Metal.
+
+- **Renderer** persists the choice between **Metal** (the default) and
+ **OpenGL**. Metal uses ANGLE's Metal backend for game rendering; OpenGL
+ uses Apple's OpenGL ES driver. Relaunch the app after changing renderers.
+ Shader/texture/command translation still starts from the shared GLES API;
+ this is not a separate handwritten Metal engine.
+- **MetalFX spatial upscaling** is shown only when Metal is selected. It renders at up to 720 lines and upscales to the
+ display resolution. It is opt-in, requires a supported physical device, and
+ is unavailable in Apple's simulator SDK. ANGLE translates GLES calls and shaders to Metal. A shared
+ IOSurface carries its image to MetalFX. Explicit synchronization adds cost,
+ so enabling it is not a guarantee of higher frame rates. It is spatial, not
+ temporal upscaling. Allocation or command-buffer failure disables upscaling and returns to
+ normal Metal presentation. OpenGL mode never enables MetalFX.
+- **Record shader stalls** records compile/link completion times and each
+ program's first draw after recording starts, keeping events ≥ 2 ms and
+ compile/link failures. First-use measurements drain earlier GPU work and
+ wait for the draw, so recording adds overhead. They can reveal deferred
+ compilation but also include drawing cost; they are not isolated compiler
+ measurements and do not cover every driver pipeline variant.
+- **Share shader report…** opens the system share sheet. Choose AirDrop and
+ your Mac. Reports contain generated GLSL, source hashes/stages, program
+ combinations, timings, device/OS/GPU and build metadata. They contain no
+ game maps or saves. Nothing is sent until you choose a sharing destination.
+ Capture retains up to 32 MiB of sources and 4096 events; dropped records are
+ counted. The five latest exports are retained in Caches/ShaderReports.
+
+The renderer and both switches persist across launches. For useful reports, enable recording,
+restart the app, visit the affected level, then share the report. Turn recording
+off again for normal play. Clear resets events and first-use tracking.
+Reports provide inputs for future shader warm-up updates; this GLES renderer
+currently has no bundled precompiled pipeline catalog. A report is not a
+portable Metal binary archive, and cannot guarantee removal of all future
+first-use stalls.
+
+For automated diagnostics, launch environments `HALO_IOS_TEST_DEBUG_UI=1`,
+`HALO_IOS_TEST_SHADER_CAPTURE=1`, and `HALO_IOS_TEST_METALFX=1` open the panel,
+start capture, or request MetalFX respectively, without changing saved settings.
+
+`HALO_IOS_TEST_RENDERER=metal` or `opengl` overrides the renderer for that
+launch only. It does not change the user's saved preference.
+
+The game and host are built from source. `tools/ios_angle.py` downloads the
+pinned ANGLE runtime frameworks packaged by
+[EdgeFirstAI/angle-package](https://github.com/EdgeFirstAI/angle-package),
+version `v2.1.28252` / source `c053bf85793bbb83016b1196d04e5df3594b9bcc`.
+The archive's SHA-256 is checked before extraction. Device and simulator
+slices are embedded separately; device frameworks are signed with the app's
+team. Public unsigned IPAs strip the vendor signatures from the embedded
+copies. Framework binaries stay in ignored build directories. The package's
+build scripts and pinned source revision are available upstream for rebuilding.
diff --git a/port/ios/RESIGNING.md b/port/ios/RESIGNING.md
new file mode 100644
index 0000000000..c8d0944d60
--- /dev/null
+++ b/port/ios/RESIGNING.md
@@ -0,0 +1,90 @@
+# Install the unsigned IPA with your own Apple account
+
+Use a Mac with Xcode and Python 3. Download the `halo-ce-ios-unsigned`
+artifact from this fork's successful iOS GitHub Actions run and unzip the
+artifact to obtain `Halo-CE-iOS-unsigned.ipa`. GitHub requires sign-in to
+download Actions artifacts. The simulator ZIP cannot run on an iPhone.
+
+The script signs locally using a certificate/private key already in your
+Keychain and a provisioning profile issued for your account. It does not
+sign in to Apple, create certificates, register devices, or bypass provisioning.
+You do not need to send your device ID to this project's maintainer when
+you use your own account.
+
+## Set up signing once
+
+1. Add your Apple account in **Xcode → Settings → Accounts**. Connect your
+ iPhone, trust the Mac, and enable Developer Mode if requested.
+2. If you do not already have a suitable iOS development profile, create a
+ temporary iOS App project in Xcode. Select your team, enable automatic
+ signing, and set a unique bundle identifier such as `com.yourname.haloce`.
+ Build/run that temporary app on your phone so Xcode creates the identity
+ and a profile covering the device. Then delete the temporary app from
+ the phone before installing Halo with the same identifier.
+3. Locate the temporary app's build product using **Products → Show in
+ Finder**, then **Show Package Contents**. Copy its
+ `embedded.mobileprovision` somewhere convenient. Alternatively, use an
+ existing development or Ad Hoc `.mobileprovision` downloaded from Apple.
+4. List available identities:
+
+ ```sh
+ security find-identity -v -p codesigning
+ ```
+
+ Use the matching **Apple Development** identity (or **Apple Distribution**
+ identity for an Ad Hoc profile). Its private key must be in your Keychain,
+ and its certificate must be included in the profile. An App Store profile
+ cannot install this build directly on a phone.
+
+## Sign and install
+
+Check out `apple/ios-web-multiplayer` from `AttilaTheFun/halo-ce-universal`.
+From its root, run:
+
+```sh
+./tools/ios_resign.sh ~/Downloads/Halo-CE-iOS-unsigned.ipa \
+ --identity 'Apple Development: Your Name (IDENTITY_ID)' \
+ --profile ~/Downloads/embedded.mobileprovision \
+ --bundle-id com.yourname.haloce \
+ --device YOUR_DEVICE_UDID \
+ --output ~/Downloads/Halo-signed.ipa
+```
+
+The exact identity name or its SHA-1 fingerprint is accepted. `--device`
+is optional but recommended: it verifies that the supplied UDID is covered.
+Use `xcrun devicectl list devices` or Xcode's Devices and Simulators window
+to identify your phone. The output must not already exist.
+
+The script checks profile expiry, platform, bundle ID, device and signing
+certificate; updates the bundle ID; signs ANGLE/SDL frameworks and the app
+inside-out; generates DER entitlements; verifies signatures; and writes a
+new IPA plus a SHA-256 checksum. It never changes the downloaded IPA.
+The shell entry point uses the adjacent `ios_resign.py`, so keep both files.
+
+Install `Halo-signed.ipa` with Apple Configurator, or extract and install
+the app with Xcode's command-line device tools:
+
+```sh
+mkdir -p ~/Downloads/Halo-signed-extracted
+ditto -x -k ~/Downloads/Halo-signed.ipa ~/Downloads/Halo-signed-extracted
+xcrun devicectl device install app --device YOUR_DEVICE_UDID \
+ ~/Downloads/Halo-signed-extracted/Payload/HaloCE.app
+xcrun devicectl device process launch --device YOUR_DEVICE_UDID com.yourname.haloce
+```
+
+On first launch, choose your own supported original Xbox Halo ISO/XISO.
+No game image or maps are included in the IPA. Keep the same account and
+bundle ID for updates to retain imported data. Renew the provisioning
+profile and re-sign before it expires; a free Personal Team's validity and
+device limits differ from paid developer membership. Back up saves before
+uninstalling. Do not upload your signed IPA/profile or private key to this
+repository; they contain your signing/device information.
+
+The script has been checked against the CI-produced unsigned IPA using a
+local development identity, including nested framework and app signature
+verification. Personal Team provisioning and third-party sideloading tools
+have not been tested for this port.
+
+Apple references: [signing setup](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices),
+[provisioning profiles](https://developer.apple.com/documentation/technotes/tn3125-inside-code-signing-provisioning-profiles),
+and [nested signatures and DER entitlements](https://developer.apple.com/documentation/xcode/using-the-latest-code-signature-format).
diff --git a/port/ios/THIRD_PARTY.md b/port/ios/THIRD_PARTY.md
new file mode 100644
index 0000000000..94c52d3726
--- /dev/null
+++ b/port/ios/THIRD_PARTY.md
@@ -0,0 +1,40 @@
+# Credits and third-party notices
+
+This iOS port builds on [cybersecurity/halo-ce-universal](https://github.com/cybersecurity/halo-ce-universal),
+the native ports in [bnunu/halo-1](https://github.com/bnunu/halo-1), and the
+decompilation work in [punpckhdq/halo](https://github.com/punpckhdq/halo).
+The inherited project license is [CC0 1.0](../../LICENSE.md).
+
+The app includes SDL 3.4.16 (zlib license), portions of musl 1.2.5 (see its
+COPYRIGHT for the MIT license and component notices), KCP (MIT), and tomlc17
+(MIT), and Expat (MIT; its COPYING is bundled in Licenses/Expat). The native XISO parser adapts upstream extraction code following
+extract-xiso (modified BSD; see `port/third_party/extract-xiso/LICENSE.TXT`).
+This product includes software developed by in . Their complete notices are copied into the built app's `Licenses`
+directory. Khronos headers retain the notices supplied by their registries.
+Other vendored components retain their original notices in the source tree.
+
+Halo, Master Chief, game artwork, and original game assets belong to their
+respective rights holders. The project license does not grant rights to those
+materials. The icon is original abstract ringworld artwork; see [ICON.md](ICON.md).
+Apple builds omit the embedded replacement HUD, title and font assets and
+load original game artwork from the user-provided disc image. The new menus
+bundle upstream XML layouts and redrawn graphics; see
+`port/assets/menus/NON_HANDDRAWN.md` for images sourced at runtime from the
+user's map and the remaining placeholders. No game maps, disc images, Microsoft signing material, or
+Xbox SDK are distributed in the app. This is an unofficial community port,
+unaffiliated with Microsoft, Bungie, or Halo Studios.
+
+## ANGLE Metal backend
+
+The Metal renderer bundles [ANGLE](https://github.com/google/angle) at
+`c053bf85793bbb83016b1196d04e5df3594b9bcc` (BSD 3-clause), using the
+`v2.1.28252` iOS/device and simulator frameworks published by
+[EdgeFirstAI/angle-package](https://github.com/EdgeFirstAI/angle-package).
+The download is pinned by SHA-256 in `tools/ios_angle.py`. ANGLE and translator
+dependency notices (xxHash, ceval, glslang, SPIR-V headers/tools/cross, Abseil,
+and zlib) are copied into the app's `Licenses` directory. ANGLE receives only
+native graphics calls; it is not a browser or a WASM runtime.
+
+- Monocypher 4.0.3, vendored from upstream `c04765d7`, implements the signed
+ public-server listings. See `port/third_party/monocypher/LICENCE.md`; that
+ license is included in both app bundles under Licenses/Monocypher.
diff --git a/port/ios/VALIDATION.md b/port/ios/VALIDATION.md
new file mode 100644
index 0000000000..c0b33075f1
--- /dev/null
+++ b/port/ios/VALIDATION.md
@@ -0,0 +1,369 @@
+# iOS/browser integration validation
+
+This record applies to `apple/ios-web-multiplayer`, based on PR #12 at
+`eaa82e6803f3d3e67c91d7f2fa2b15ee04e195f5` and the iOS fork at
+`3f2c14101d3ae1c7f0c0a11993a43407fadbeb46`. Updated 2026-10-04.
+Reports of gameplay on the original iOS fork are not validation of this merge.
+
+## Main refresh — 0.1.9 (13), 2026-10-04
+
+Integrated upstream main `193cbf59` and retained PR #12 head `eaa82e68` (no
+newer WASM commits were available). This replaces the default native list
+with upstream's in-game PC menus/browser, retaining Apple rendering, imports,
+controller handling and the optional WebRTC transport. Expat and SDL key-name
+bridges are included; Apple embeds 204 menu files, zero HUD replacements and
+zero replacement fonts. Original logos, map pictures and other game assets
+continue to come from the user's image.
+
+- Both Release Apple apps build. Signed 0.1.9 installed and launched normally
+ on iPhone 15 Pro (A17 Pro); Metal, cached shaders, touch controls and audio
+ start successfully. Physical phone main-menu screenshot inspected.
+- Mac displayed live signed listings and their focused row, reached the
+ selected host and displayed the game-lobby preview. Created a saved profile
+ through the actual menus, then joined `the BIG CTF`: admitted as machine 22,
+ player added, Blood Gulch loaded and client entered the ongoing game. Own
+ framebuffer captured; timed test exited cleanly. This is a smoke test, not
+ sustained play validation. One earlier test run overlapped another local
+ client and failed to bind its port; the successful run had no competing app.
+- `tools/ios_test.py` passes (including 16 XISO cases and native socket and
+ quick-play tests). The 132 `tools/tests/*.test.cjs` checks pass. Production
+ player rejoin/ownership, client lifecycle and four checkpoint runtime tests
+ pass under their sanitizer harnesses; fixtures now cover main's reusable
+ quit-player slots and the new picked-up-weapon callback.
+- Eight applicable `tools/test_linux_port.py` checks pass, including menu XML
+ and setting validation. Three Linux ABI/link/listing checks require a Linux
+ toolchain/sysroot and do not run successfully on this macOS host.
+- Browser host policy, solo-host/end-game, migration-manager, connection-idle,
+ player-name and pending-rejoin harnesses also pass. A regression caught the
+ solo-host early return bypassing main's new time limit; the updated predicate
+ keeps solo hosts open only while respecting the configured time limit.
+- Device signature and app bundle exclusions checked. No new Mac notarization
+ or MacBook delivery was performed for this revision.
+- Sustained iOS matches, split-screen co-op, every new in-game setting and
+ browser crossplay/migration remain unverified in this revision. Published
+ PR #12 web demos were protocol 6 at the last check; native protocol remains
+ 11. This refresh does not claim to resolve that incompatibility.
+
+## Observed locally
+
+- Unsigned ARM64 iPhone/iPad app and IPA built with Xcode 27 and LLVM/LLD 23.1.2.
+- ARM64 iOS simulator app compiled and linked, installed and launched on the
+ iPhone 18 Pro / iOS 27 simulator. With the user's NTSC-US v5 maps
+ (`01.10.12.2276`), the native main menu and Beaver Creek gameplay rendered;
+ the audio callback reported active PCM output. This is simulator evidence,
+ not physical-device performance or a listening test.
+- The full-disc ISO uses partition offset `0x18300000`. Native extraction and
+ the inspection helper produced byte-identical results for all 24 maps
+ (1,860,638,720 bytes); SHA-256 hashes were compared locally. The helper now
+ recognizes that offset, which the native and browser importers already used.
+- Real game crossplay with Chromium's WASM build from CI revision `221ba03e`:
+ the browser joined the native host's running match at tick 2991, then the
+ native client joined a fresh browser-hosted match at tick 592. Both rendered
+ Beaver Creek and the host logs confirmed the remote player finished loading.
+ Both processes ran on the same Mac, using public signaling and WebRTC.
+- Host departure worked in both directions: after terminating iOS, Chromium
+ adopted the live match at tick 4542 / epoch 1; after closing the browser host,
+ iOS adopted the live match at tick 1279 / epoch 1. The engine logged preserved
+ match state. Score, movement/fire replication and extended continuity have
+ not yet been systematically verified.
+- A cold restart of the original iOS host could not join its former match
+ after the browser took over: the browser server rejected the new connection
+ because its selected machine slot was occupied. This is a known failing
+ scenario, distinct from live-client host migration and fresh-room joining.
+- Signed native ILP32 execution probe passed: layout, global/stack access,
+ indirect calls, atomics and pointer zero-extension.
+- Concurrent memory protection/tracking and zeroed reused mappings passed.
+- Audio handoff passed 100 real SDL callbacks and 134,144 exact PCM samples.
+- Display sizing and sixteen synthetic XISO regressions passed under sanitizers.
+- Native virtual sockets passed packet framing, ring/counter wraparound,
+ backpressure and ordered control-queue tests under ASan/UBSan.
+- JavaScript suite: all 132 tests passed, including six new native adapter
+ tests, including delayed acknowledgements, all 128 ping rows and migration/reconnect callbacks.
+- A real macOS WebKit-to-Chromium WebRTC connection carried 4,096 exact datagram
+ bytes and 262,144 exact ordered reliable-stream bytes from native virtual
+ sockets and back through PR #12's transport.
+ This tests the network bridge, not the game or an iPhone.
+
+All three CI workflows passed at `221ba03e`: browser/relay/tests, iOS unsigned
+IPA/simulator/probes, and Android/Linux/Windows builds. See the fork
+[Actions runs](https://github.com/AttilaTheFun/halo-ce-universal/actions)
+for revision-specific results. The asset-based simulator tests above used the
+same engine with simulator-only launch settings added to bypass the chooser;
+those settings do not affect device builds. Local host migration tests also
+pass with the `HALO_IOS_BROWSER` feature guard and reject original failure cases.
+
+## Menu and controller follow-up
+
+- Normal launch now opens Halo's main menu. The signed update was installed on
+ the designated iPhone 13 Pro (iOS 26.7.1); its screenshot confirms the menu,
+ and its log reports 194 resolved imports and active PCM output.
+- System Link's menu handler now opens the room chooser after startup. The
+ native form uses the keyboard layout guide and a scrollable field area;
+ a landscape simulator screenshot with the keyboard visible confirms the
+ code field and both action buttons remain on screen.
+- The real quick-play state machine passes deferred-start and repeat-start
+ tests; stale packet/control/ping state is cleared between room selections.
+ A simulator room launch still reaches Beaver Creek gameplay.
+- SDL hardware-controller routing retains the connected primary gamepad,
+ balances its owned open/close reference, logs changes and automatically
+ hides/restores touch controls. The simulator's gamepad triggers hiding;
+ the user subsequently confirmed an Xbox Series X controller works on the
+ phone. Backbone/PlayStation hardware behavior remains unverified. The touch
+ visibility toggle now hides along with the controls while connected.
+- The iPhone 15 Pro is excluded from further device testing at the user's
+ request. Physical-device tests now target only the iPhone 13 Pro.
+
+## Reproduce
+
+```sh
+python3 tools/ios_test.py
+node --test tools/tests/*.test.cjs
+python3 tools/ios_build.py --unsigned --ipa dist/Halo-CE-iOS-unsigned.ipa
+python3 tools/ios_build.py --simulator
+```
+
+Optional real-network transport test on an Apple Silicon Mac with a GUI session:
+
+```sh
+npm install --prefix build/network-test playwright
+build/network-test/node_modules/.bin/playwright install chromium
+node tools/test_ios_webrtc.cjs
+```
+
+This creates a random isolated room on the public signaling services, starts
+Chromium and a native WebKit probe, and checks datagrams and reliable streams.
+It needs internet access but no game assets or signing account. It is not run
+by default CI, because external signaling and GUI availability are variable.
+
+## Required before marking ready
+
+- Install a personally signed build on an iPhone/iPad and import the user's
+ original maps. Validate menu, campaign, controls, rendering and audio.
+- Repeat the successful simulator join/host/migration tests on hardware with
+ matching browser maps. Verify movement/fire replication, scores, player
+ identity and checkpoint continuity over extended play.
+- Resolve cold-restart admission after host migration; verify reconnect paths.
+- Test separate internet connections and TURN-only connectivity.
+- Measure frame time, latency, memory and extended play on hardware.
+- Exercise app background/resume and WebKit process termination; recovery is
+ not yet a validated promise.
+
+The user supplied a compatible original Xbox disc image. Real native/browser
+matches now have simulator evidence; physical-device behavior and the remaining
+cases above are still unverified. Keep the PR in draft until those checks pass.
+
+## Graphics diagnostics (2026-10-03)
+
+- Fixed GLES water draws losing their destination framebuffer after ripple
+ mipmap blits. A/B simulator runs of `levels\b30\b30` show a flat dark water
+ surface with the old ordering and ripples after restoring targets/raster
+ state following texture preparation. Other existing simulator scene artifacts
+ remain; this is not a claim of complete rendering correctness.
+- iPhone 13 Pro executed MetalFX spatial scaling from 1558×720 to 2532×1170.
+ The menu rendered with the Metal-backed presentation path. This establishes
+ execution, not a sustained-performance improvement.
+- Simulator debug panel and system share sheet rendered in landscape.
+ After dismissal the controls regain first-responder status; injecting the
+ UIKit shake event reopens the debug panel. Physical shake input remains a
+ user check.
+ An exported report parsed successfully with 57 source records and 44
+ first-use draw waits; every event source reference resolved. The simulator
+ correctly disables MetalFX. Actual AirDrop delivery requires user selection
+ on the phone and has not been tested by automation.
+- Runtime ABI, memory, audio, display sizing, 16 XISO tests, and room bridge
+ regression checks pass. Full signed-device and simulator host builds pass.
+- macOS menu routing and a separate native macOS renderer remain unvalidated.
+
+## Persisted renderer selection (2026-10-03)
+
+- Default startup selects ANGLE's Metal backend. Simulator logs identify its
+ Metal renderer; iPhone 13 Pro logs identify the Apple A15 Metal GPU. The
+ alternative path uses Apple's EAGL/OpenGL ES driver, with separate symbol
+ dispatch throughout guest rendering, shader diagnostics, and presentation.
+- MetalFX runs on the iPhone 13 Pro using an ANGLE IOSurface → MetalFX handoff
+ at 1558×720 → 2532×1170. It is unavailable in OpenGL mode and in the simulator.
+- Normalize decoded texture uploads to RGBA on iOS. This fixes the red/blue
+ reversal seen with texture swizzling in the ANGLE simulator. Silent
+ Cartographer now shows blue sky, textured geometry and water ripples.
+- Simulator UI control actions save OpenGL and remove the MetalFX row; the
+ following launch logs OpenGL. Metal selection restores the row and the next launch logs Metal; it is saved
+ independently of the upscaling preference. Changes apply on relaunch.
+- Signed device, simulator, and unsigned IPA builds pass. The public IPA
+ contains unsigned embedded framework copies and bundled license notices.
+ Native runtime/layout, memory, audio, display, XISO and room checks pass.
+- A Metal shader-report export identifies the ANGLE backend and validates
+ all source references (13 shader sources, 7 recorded events in the menu run).
+- This adds a Metal backend via ANGLE, not a handwritten Metal renderer.
+ Extended campaign/crossplay performance and all-device coverage remain open.
+
+## Private-image storage and native Mac target (2026-10-03)
+
+- Removed the touch visibility toggle; iPhone controls remain automatic and
+ the native AppKit target creates no touch overlay.
+- Imported the user's full 7,825,162,240-byte disc through the shared private
+ storage transaction. SHA-256 readback matched; all original maps validated.
+- Six synthetic storage tests pass: retained image and restart without source,
+ cancellation preserving current data, corrupt import preserving current data,
+ truncated stored image rejection, pointer traversal rejection, and cleanup
+ restricted to owned unpublished generations. Existing 16 XISO parser tests
+ and ABI/memory/audio/display/room tests also pass.
+- iPhone 13 Pro: native first-launch import screen observed; a verified private
+ image generation was then transferred to its container for migration testing.
+ Relaunch reached Metal main menu with 198 resolved imports, touch controls
+ visible, and no hide/show toggle. The full Files-picker import transaction
+ was exercised in the shared native importer, not driven end-to-end by taps
+ on this phone. iPhone 15 Pro was not used.
+- Mac: native main-menu and Silent Cartographer framebuffers captured, with
+ audio. Developer ID/hardened-runtime app launched successfully inside its
+ sandbox using its private Application Support generation. Initial MetalFX
+ testing found an inactive-window lookup; using the SDL Cocoa window fixed
+ that, with scaler execution confirmed. Performance and interactive controller,
+ menu, and crossplay coverage remain outstanding.
+- Simulator build passes, but this session's simulator launch service stalled;
+ the device checks above replace that attempted UI smoke test, not a claimed
+ simulator pass.
+- Unsigned IPA and signed DMG contain no user disc image or maps. Replacement
+ HUD/title/font payloads are disabled in Apple guest builds, and the app icon
+ is original geometric art. Signing does not establish legal clearance.
+- The ring-artwork Mac release built from `55bbbf7e` was accepted by Apple
+ on 2026-10-03. App submission: `2ee63744-83dd-4188-8f02-3c3b74697397`;
+ DMG submission: `890c6e2f-6bdb-4678-883f-e5b452746859`. Both tickets were
+ stapled and validated. Gatekeeper accepted the final DMG and the app mounted
+ from it as `Notarized Developer ID`. The final package was also checked for
+ accidental game/private data. Actual AirDrop delivery remains unverified.
+
+## Settings and menu audio update (0.1.4 / 8)
+
+- macOS Settings is a reusable separate window under the application menu
+ (⌘,); iOS retains its shake-presented modal, now titled Settings.
+- Reproduced music starvation in a real eight-second native menu tracking
+ session: servicing DirectSound completions alone drained to zero PCM.
+ Servicing the guarded sound streaming update on the guest main thread kept
+ PCM active throughout two subsequent eight-second menu sessions and after
+ closing. The SDL worker never invokes the game's completion callbacks.
+- Fixed black generated icon assets by replacing offscreen AppKit drawing
+ with CoreGraphics resizing. Visually inspected the regenerated ring icon;
+ both Mac ICNS and iOS catalog now use the correct bitmap.
+- Native ABI, memory, audio callback/deadlock, display sizing, 16 XISO cases
+ and room bridge tests pass. Both physical-device and Mac release builds pass.
+- At the user's updated request, installed 0.1.4 (8) on iPhone 15 Pro
+ (`iPhone16,1`, A17 Pro, iOS 27.0.1) and copied the verified private disc/map
+ generation into its app container. Normal launch reached the main menu,
+ with 199 resolved imports, Metal rendering, active PCM output and touch
+ controls. Existing saves were preserved. No controller was connected during
+ this smoke test; the earlier iPhone 13 Pro controller evidence is unchanged.
+- The Mac app from `37ccd843` was accepted by Apple (submission
+ `a0576c71-190f-47fd-9982-698422a89e1a`). Its ticket was stapled and validated;
+ Gatekeeper accepted it as Notarized Developer ID. Packaged the stapled app
+ as `HaloCE-0.1.4-AppleSilicon.zip` and successfully sent it to the user's
+ MacBook through Taildrop. No DMG is required for this local test release.
+
+## First-mission feedback / 0.1.5 (9)
+
+- User completed the first mission on Mac and reported compilation hitches,
+ two transparent surfaces (floor panel and escape-pod front), escape-pod
+ jitter in the ending cinematic, and nonfunctional Dock Quit.
+- The received report identified build 7 / `55bbbf7e`, ANGLE Metal on M4 Max,
+ 158 sources, zero timing events and zero dropped events. Replaying all 158
+ shader sources with the bundled ANGLE driver on M6 compiled successfully.
+ This does not rule out link, texture, material, draw-state or geometry bugs
+ on the reported M4 Max. The two visual bugs are still unresolved; no
+ speculative rendering or animation patch has been applied.
+- Fixed AppKit creation order: initializing SDL video first installs
+ SDL3Application and its standard app menu. Before this, pre-creating
+ NSApplication skipped both menu setup and the terminate-to-quit bridge.
+ Reuses SDL’s Settings placeholder to avoid AppKit stealing Cmd+, from a
+ duplicate menu item. Real application-menu Quit and the Dock termination selector both produce
+ a guest quit event and exit 0. Settings is tested through the actual menu.
+- Added a bounded, GPU/OS/driver-scoped persistent ANGLE blob cache. A fresh
+ launch loaded a compiled blob from disk. Callback tests cover disk reload,
+ small/negative output buffer sizes, concurrent writes and 64 MiB eviction.
+ First-use Metal pipeline specialization can still hitch; no frame-time
+ performance claim is made from cache hits alone.
+- Schema 2 shader reports record compile/link stalls even with optional GPU
+ recording off, and include program/source associations. A real menu run
+ exported a valid report with 11 programs / 13 sources, all references
+ resolved, and a timing event while first-use GPU recording was false.
+- The signed sandboxed 0.1.5 app launched with its private image store,
+ populated its shader cache, opened Settings through the application menu,
+ and exited 0 through Quit. Native menu audio remained active through an
+ 8.24-second tracking session. Mac and iOS device builds and runtime/cache
+ probes passed; this update was not installed on an iPhone.
+- Apple accepted the app from `1235f980` (submission
+ `47e2d143-3182-4e31-aa4b-43bdd1e69ff3`); its ticket was stapled and Gatekeeper
+ accepted it as Notarized Developer ID. Sent `HaloCE-0.1.5-AppleSilicon.zip`
+ to the user's MacBook via Taildrop. The visual mission bugs above remain
+ open pending a reproducible scene/checkpoint or clip.
+
+## Application naming / 0.1.6 (10)
+
+- Distributed Mac bundle: `Halo: Combat Evolved.app`. Menu heading and SDL
+ application metadata use the full title; localized InfoPlist strings give
+ Finder/Dock the short label `Halo`. The native Foundation display-name
+ lookup on the built, correctly named bundle returns `Halo`; localized
+ CFBundleName remains `Halo: Combat Evolved`. iOS CFBundleDisplayName is
+ `Halo`. Bundle identifiers and the private data locations are unchanged.
+- Installed and launched on iPhone 15 Pro; CoreDevice lists `Halo`, 0.1.6
+ (10). Mac Settings/Quit smoke checks pass. Apple accepted the app from
+ `2e6febc6` (submission `1c361f6d-56c2-4aab-8dce-7628b6fc5232`); stapling
+ and Gatekeeper checks pass. After extracting the final ZIP, the raw bundle
+ name, full application name and short localized icon label still match.
+ Sent `Halo-0.1.6-AppleSilicon.zip` to the MacBook using Taildrop.
+
+## Public servers — 0.1.7 (11), 2026-10-04
+
+- Signed iPhone 15 Pro build installed as `com.LoganShire.HaloCE`, preserving
+ imported game data and saves. Native networking is now the default.
+- Physical phone discovered 14 signed protocol-11 public listings. An initial
+ join investigation exposed the missing Darwin `recvmsg` sockaddr conversion;
+ the new native TCP/UDP sanitizer test reproduces that defect and passes with
+ the fix. This is separate from the earlier public-WASM protocol-6 mismatch.
+- Real Mac ARM64 client joined `[D] Slayer` through public MQTT discovery and
+ the native internet tunnel; the game logged successful machine admission,
+ player registration and `Multiplayer playing: Multiplayer is ready.` No
+ local fixture or WebRTC relay was used. This is a join/load smoke test,
+ not a sustained match or movement/fire replication test.
+- The first native join exposed upstream's protocol-11 settings extension
+ (13,092 to 13,120 bytes). Imported the PC game-rule extension from `c9ee319a`
+ and its client gameplay handling. Older incoming records expand to default
+ rules; Apple browser-room hosting emits the old settings layout. Browser
+ crossplay and mixed-version checkpoint migration need fresh validation;
+ the historical results above do not establish those for 0.1.7.
+- Quick-play timing regression: selecting a room/server inside the native
+ command pump advances the clock. Refreshing `now` after pumping avoids an
+ unsigned-wrap timeout immediately after selection. The production state
+ machine test covers this and passes with sanitizers.
+- `python3 tools/ios_test.py` passed: guest ABI/memory, audio, display, 16 XISO
+ tests, virtual room queues/sockets, shader cache, real Darwin TCP/UDP and
+ quick-play lifecycle. Both Apple release targets compiled successfully.
+- Server UI is an Apple presentation of upstream `c04765d7` P2P discovery,
+ not PR #69's optional HTTP list. Native automatic UPnP and hosting UI are
+ outside this change; NAT reachability is not guaranteed for every server.
+
+Developer-only launch variables: `HALO_NATIVE_TEST_BROWSER=1` opens the browser
+20 seconds after game event pumping starts; optional `HALO_NATIVE_TEST_JOIN`
+selects exactly one named server once. Neither is set in ordinary launches.
+
+## Visible join progress — 0.1.8 (12), 2026-10-04
+
+- The reported phone join to MassiveFlagWar was accepted by the server and
+ reached player setup; no rejection was logged before event processing stopped.
+ The old UI exposed the main menu while asynchronous setup continued.
+- Keep a native progress window over the menus while the game continues its
+ normal frame/network loop. Restore SDL keyboard focus, show engine setup
+ phases, allow cancellation, and dismiss only for gameplay or a terminal
+ result. Early termination now produces a visible explanation.
+- The updated Mac client late-joined MassiveFlagWar with 35 players and
+ reached gameplay. The iPhone 15 Pro then joined with 36 players: native
+ progress appeared, machine/player admission completed, Blood Gulch loaded,
+ and `Multiplayer playing` dismissed progress. A screenshot verified the
+ landscape progress presentation and Cancel control.
+- Found a test-only interference: the delayed automatic browser launch fired
+ at 20 seconds after the user had already manually joined at 17 seconds. It
+ disposed the live client, reopened the browser, and caused a client assertion.
+ Manual browser/room opening now consumes that scheduled test navigation.
+ Final signed installation was launched WITHOUT automatic navigation/join
+ environment variables. This smoke test is not sustained gameplay validation.
+- Both Apple release builds pass, the device signature verifies, and the full
+ `tools/ios_test.py` suite passes. Existing campaign rendering issues and
+ browser/WebRTC interoperability limitations are unchanged.
diff --git a/port/ios/guest.ld b/port/ios/guest.ld
new file mode 100644
index 0000000000..a5cf49f30c
--- /dev/null
+++ b/port/ios/guest.ld
@@ -0,0 +1,21 @@
+/* Guest offsets remain Xbox-sized; the host maps these above 4 GB. */
+ENTRY(__guest_header)
+PHDRS { code PT_LOAD FLAGS(5); data PT_LOAD FLAGS(6); }
+SECTIONS {
+ . = 0x88000000;
+ .guest_header : { KEEP(*(.guest_header)) } :code
+ .text : { *(.text .text.*) } :code
+ .rodata : { *(.rodata .rodata.*) } :code
+ . = ALIGN(0x4000);
+ __guest_code_end = .;
+ .data : { *(.data .data.*) } :data
+ .guest_init : {
+ __guest_init_start = .;
+ KEEP(*(.guest_init))
+ __guest_init_end = .;
+ } :data
+ .bss : { *(.bss .bss.*) *(COMMON) } :data
+ . = ALIGN(0x4000);
+ __guest_image_end = .;
+ /DISCARD/ : { *(.comment) *(.note.*) *(.guest_fini) *(.eh_frame) }
+}
diff --git a/port/ios/host/guest_call.S b/port/ios/host/guest_call.S
new file mode 100644
index 0000000000..e881b7b339
--- /dev/null
+++ b/port/ios/host/guest_call.S
@@ -0,0 +1,33 @@
+/* Preserve the host ABI while installing the guest arena register. */
+.text
+.p2align 2
+.globl _host_guest_invoke
+_host_guest_invoke:
+ stp x27, x30, [sp, #-16]!
+ mov x16, x0
+ mov x27, x5
+ mov w0, w1
+ mov w1, w2
+ mov w2, w3
+ mov w3, w4
+ blr x16
+ ldp x27, x30, [sp], #16
+ ret
+
+/* Run the game on the UI thread, using an arena stack for its 32-bit locals. */
+.globl _host_guest_on_stack
+_host_guest_on_stack:
+ stp x27, x30, [sp, #-32]!
+ str x29, [sp, #16]
+ mov x9, sp
+ mov sp, x3
+ stp x9, xzr, [sp, #-16]!
+ mov x27, x2
+ mov x16, x0
+ mov w0, w1
+ blr x16
+ ldr x9, [sp]
+ mov sp, x9
+ ldr x29, [sp, #16]
+ ldp x27, x30, [sp], #32
+ ret
diff --git a/port/ios/host/host.h b/port/ios/host/host.h
new file mode 100644
index 0000000000..8adf839430
--- /dev/null
+++ b/port/ios/host/host.h
@@ -0,0 +1,95 @@
+/*
+HOST.H
+
+Services provided by the native iOS host. See
+port/ios/README.md for the overall design and
+port/runtime/include/halo_guest_abi.h for the guest contract.
+*/
+
+#ifndef __HALO_NATIVE_HOST_H
+#define __HALO_NATIVE_HOST_H
+
+#include
+#include
+#include
+
+#include "halo_guest_abi.h"
+
+/* ---------- logging */
+
+void host_logf(int priority, const char *format, ...) __attribute__((format(printf, 2, 3)));
+#define HOST_LOG_INFO 4
+#define HOST_LOG_WARN 5
+#define HOST_LOG_ERROR 6
+/* guest services also used inside the host (host_main.c) */
+void host_exit(int code) __attribute__((noreturn));
+int host_errno(void);
+
+/* logs, shows the message to the player and terminates */
+void host_fatal(const char *format, ...) __attribute__((format(printf, 1, 2), noreturn));
+
+/* ---------- guest memory (host_memory.c)
+
+All guest pointers are 32-bit offsets into an aligned native arena. The host
+reserves the Xbox window and signed image aliases at startup and allocates
+other guest memory from that arena. Native pointers remain 64-bit. */
+
+/* reserves the fixed ranges; returns 0 on success */
+int host_memory_initialize(uint32_t image_base, uint32_t image_size);
+/* page-granular allocations within the guest arena; NULL on failure */
+void *host_low_map(size_t size, int protection);
+void host_low_unmap(void *address, size_t size);
+/* 1 if [address, address + size) was handed out by host_low_map or is one of
+the fixed ranges */
+int host_low_owns(uintptr_t address, size_t size);
+/* the guest's mmap/munmap/mprotect/madvise/mremap (host_syscall.c) */
+long host_guest_mmap(uint64_t address, uint64_t size, int protection, int flags, int fd, int64_t offset);
+long host_guest_munmap(uint64_t address, uint64_t size);
+long host_guest_mprotect(uint64_t address, uint64_t size, int protection);
+
+/* ---------- the guest image (host_loader.c) */
+
+struct host_guest_image
+{
+ const struct halo_guest_header *header;
+ uint32_t base, end;
+};
+
+extern struct host_guest_image host_image;
+
+/* aliases the embedded signed image into the arena; returns 0 on success */
+int host_load_image(const void *elf, size_t size);
+
+/* ---------- entering guest code (host_thread.c) */
+
+/* calls the guest function at address with up to four 32-bit arguments on
+this thread, which must have been made by host_native_thread_create (giving
+the thread a guest struct pthread first if it has none); returns the
+guest's w0 */
+uint32_t host_call_guest(uint32_t function, uint32_t a, uint32_t b, uint32_t c, uint32_t d);
+/* starts a thread running function(argument) with its stack in guest
+memory, so that it can call guest code; the stack is freed after it exits.
+Returns 0 or an errno value */
+int host_native_thread_create(void *(*function)(void *), void *argument, size_t stack_size);
+/* runs the guest's __guest_start on the calling thread (one made by
+host_native_thread_create); does not return */
+void host_run_guest_main(uint32_t boot) __attribute__((noreturn));
+
+/* ---------- debugging (host_debug.c) */
+
+void host_debug_thread_started(void);
+void host_debug_thread_exited(void);
+/* config.toml's debug.sample_seconds: seconds between samples of the guest
+threads, as text */
+void host_debug_start_sampler(const char *setting);
+
+/* ---------- import table (host_imports.c) */
+
+/* the host function for an import name, or NULL */
+void *host_resolve_import(const char *name);
+
+/* ---------- SDL / GL (host_sdl.c, host_gl.c) */
+
+void *host_gl_resolve(const char *name);
+
+#endif
diff --git a/port/ios/host/host_debug.h b/port/ios/host/host_debug.h
new file mode 100644
index 0000000000..c2c2888487
--- /dev/null
+++ b/port/ios/host/host_debug.h
@@ -0,0 +1,22 @@
+#pragma once
+#include
+void halo_debug_present(void);
+void halo_debug_install_menu(void);
+void halo_debug_initialize(void);
+int halo_debug_is_presented(void);
+void halo_debug_use_program(uint32_t program);
+double halo_debug_draw_begin(void);
+void halo_debug_draw_end(double started);
+void halo_debug_shader_source(uint32_t shader,int count,const char *const *sources,const int *lengths);
+void halo_debug_attach(uint32_t program,uint32_t shader);
+double halo_debug_begin(void);
+void halo_debug_end(uint32_t object,int program,double started);
+void halo_debug_forget(uint32_t object,int program);
+int halo_metalfx_enabled(void);
+void halo_metalfx_set_enabled(int enabled);
+int halo_metalfx_supported(void);
+int host_ios_metalfx_present(unsigned int texture,unsigned int width,unsigned int height,unsigned int output_width,unsigned int output_height);
+unsigned int host_ios_render_height(void);
+
+void halo_debug_replay_shader_report(void);
+void halo_debug_test_export(void);
diff --git a/port/ios/host/host_debug.m b/port/ios/host/host_debug.m
new file mode 100644
index 0000000000..8aa5198222
--- /dev/null
+++ b/port/ios/host/host_debug.m
@@ -0,0 +1,322 @@
+/* Shader-source diagnostics are exportable data, never game maps or saves. */
+#if HALO_MACOS
+#import
+#else
+#import
+#endif
+#import
+#include
+#import
+#include
+#include
+#include
+#include "host_debug.h"
+#include "ios_host.h"
+#include "host_gl_dispatch.h"
+
+static atomic_bool recording;
+static NSMutableDictionary *shaderKeys,*sources,*attachments;
+static NSMutableArray *events;
+static NSMutableSet *drawnPrograms;
+static uint32_t currentProgram;
+static NSString *glRenderer;
+#ifndef HALO_BUILD_REVISION
+#define HALO_BUILD_REVISION "unknown"
+#endif
+static NSUInteger sourceBytes,dropped;
+#if HALO_MACOS
+static BOOL debugPresented;
+#else
+static __weak UIViewController *debugController;
+#endif
+void halo_debug_initialize(void) {
+ NSUserDefaults *settings=NSUserDefaults.standardUserDefaults;
+ atomic_store(&recording,[settings boolForKey:@"HaloRecordShaderStalls"] || [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_SHADER_CAPTURE"] boolValue]);
+ halo_metalfx_set_enabled([settings boolForKey:@"HaloMetalFX"] || [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_METALFX"] boolValue]);
+}
+static void prepare(void) {
+ if(!shaderKeys){shaderKeys=[NSMutableDictionary new];sources=[NSMutableDictionary new];attachments=[NSMutableDictionary new];events=[NSMutableArray new];drawnPrograms=[NSMutableSet new];}
+}
+void halo_debug_shader_source(uint32_t shader,int count,const char *const *strings,const int *lengths) {
+ prepare();if(!glRenderer){const GLubyte *name=glGetString(GL_RENDERER);if(name)glRenderer=[NSString stringWithUTF8String:(const char *)name];}
+ [shaderKeys removeObjectForKey:@(shader)];NSMutableData *data=[NSMutableData new];
+ for(int i=0;i=0?(NSUInteger)lengths[i]:strlen(strings[i]);
+ if(length>1024*1024 || data.length+length>1024*1024){dropped++;return;}
+ [data appendBytes:strings[i] length:length];
+ }
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];CC_SHA256(data.bytes,(CC_LONG)data.length,digest);
+ NSMutableString *key=[NSMutableString new];for(int i=0;i32*1024*1024){dropped++;return;}
+ GLint type=0;glGetShaderiv(shader,GL_SHADER_TYPE,&type);
+ sources[key]=@{@"source":text,@"type":type==GL_VERTEX_SHADER?@"vertex":@"fragment"};sourceBytes+=data.length;
+ }
+ shaderKeys[@(shader)]=key;
+}
+void halo_debug_attach(uint32_t program,uint32_t shader) {
+ prepare();NSMutableArray *list=attachments[@(program)];
+ if(!list){list=[NSMutableArray new];attachments[@(program)]=list;}
+ NSString *key=shaderKeys[@(shader)];if(key && ![list containsObject:key])[list addObject:key];
+}
+double halo_debug_begin(void) {return CACurrentMediaTime();}
+void halo_debug_end(uint32_t object,int program,double started) {
+ if(!started)return;
+ GLint ok=0;
+ if(program)glGetProgramiv(object,GL_LINK_STATUS,&ok);else glGetShaderiv(object,GL_COMPILE_STATUS,&ok);
+ double ms=(CACurrentMediaTime()-started)*1000;
+ if(ms<2 && ok)return;
+ prepare();if(events.count>=4096){dropped++;return;}
+ NSArray *keys=program?(attachments[@(object)]?:@[]):(shaderKeys[@(object)]?@[shaderKeys[@(object)]]:@[]);
+ [events addObject:@{@"stage":program?@"link":@"compile",@"milliseconds":@(ms),@"success":@(ok!=0),@"sources":keys,@"time":@([NSDate date].timeIntervalSince1970)}];
+}
+void halo_debug_use_program(uint32_t program) {currentProgram=program;}
+double halo_debug_draw_begin(void) {
+ if(!atomic_load(&recording) || !currentProgram)return 0;
+ prepare();if([drawnPrograms containsObject:@(currentProgram)])return 0;
+ [drawnPrograms addObject:@(currentProgram)];
+ /* Drain older work outside the timed interval. This is deliberately
+ intrusive diagnostics, never enabled during normal rendering. */
+ glFinish();return CACurrentMediaTime();
+}
+void halo_debug_draw_end(double started) {
+ if(!started)return;
+ glFinish();double ms=(CACurrentMediaTime()-started)*1000;
+ if(ms<2)return;
+ if(events.count>=4096){dropped++;return;}
+ [events addObject:@{@"stage":@"first_draw_wait",@"milliseconds":@(ms),@"sources":[attachments[@(currentProgram)] copy]?:@[],@"time":@([NSDate date].timeIntervalSince1970)}];
+}
+#if HALO_MACOS
+int halo_debug_is_presented(void) {return debugPresented;}
+#else
+int halo_debug_is_presented(void) {return debugController!=nil;}
+#endif
+void halo_debug_forget(uint32_t object,int program) {
+ if(program)[drawnPrograms removeObject:@(object)];
+ if(program)[attachments removeObjectForKey:@(object)];else [shaderKeys removeObjectForKey:@(object)];
+}
+#if HALO_MACOS
+static void exportReport(id presenter) {
+#else
+static void exportReport(UIViewController *presenter) {
+#endif
+ prepare();struct utsname device;uname(&device);
+ NSMutableDictionary *programs=[NSMutableDictionary new];
+ for(NSNumber *name in attachments)programs[name.stringValue]=[attachments[name] copy];
+ NSDictionary *report=@{@"schema":@2,@"renderer":halo_graphics_metal()?@"Metal (ANGLE)":@"OpenGL ES (Apple)",@"gpu":glRenderer?:@"unknown",@"revision":@HALO_BUILD_REVISION,@"metalfx":@(halo_metalfx_enabled()!=0),@"device":[NSString stringWithUTF8String:device.machine],@"os":NSProcessInfo.processInfo.operatingSystemVersionString,
+ @"build":[NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"]?:@"unknown",@"threshold_ms":@2,
+ @"note":@"CPU compile/link completion timings are always recorded. Synchronized first-use draw waits are opt-in. First-use waits include draw cost and may include deferred driver compilation; they are not isolated shader-compiler timings. Shader sources can guide warm-up updates.",
+ @"first_use_gpu_recording":@(atomic_load(&recording)),@"programs":programs,
+ @"dropped":@(dropped),@"events":[events copy],@"sources":[sources copy]};
+ NSError *error=nil;NSData *data=[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:&error];
+ NSURL *folder=[[NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory inDomains:NSUserDomainMask].firstObject URLByAppendingPathComponent:@"ShaderReports" isDirectory:YES];
+ [NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:&error];
+ NSArray *oldReports=[[NSFileManager.defaultManager contentsOfDirectoryAtPath:folder.path error:nil] filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *name,NSDictionary *bindings){(void)bindings;return [name hasPrefix:@"halo-shaders-"] && [name hasSuffix:@".json"];}]];
+ oldReports=[oldReports sortedArrayUsingSelector:@selector(compare:)];
+ for(NSUInteger i=0;i+4
+@end
+@implementation HaloDebugController
+- (void)viewDidLoad {
+ [super viewDidLoad];self.title=@"Settings";
+ self.navigationItem.rightBarButtonItem=[[UIBarButtonItem alloc]initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(done)];
+}
+- (void)done {[self dismissViewControllerAnimated:YES completion:^{debugController=nil;host_ios_touch_focus();}];}
+- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController {
+ (void)presentationController;debugController=nil;host_ios_touch_focus();
+}
+- (NSInteger)tableView:(UITableView *)table numberOfRowsInSection:(NSInteger)section {(void)table;(void)section;return halo_graphics_prefer_metal()?6:5;}
+- (UITableViewCell *)tableView:(UITableView *)table cellForRowAtIndexPath:(NSIndexPath *)path {
+ (void)table;UITableViewCell *cell=[[UITableViewCell alloc]initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil];
+ cell.detailTextLabel.numberOfLines=0;
+ BOOL selectedMetal=halo_graphics_prefer_metal();
+ if(path.row==(selectedMetal?5:4)) {
+ cell.textLabel.text=@"Browser rooms (experimental)";
+ cell.detailTextLabel.text=@"Off: public server browser. On: WebRTC room codes. Relaunch after changing.";
+ UISwitch *toggle=[UISwitch new];toggle.on=[NSUserDefaults.standardUserDefaults boolForKey:@"HaloBrowserRooms"];
+ [toggle addTarget:self action:@selector(networkChanged:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;
+ } else if(path.row==0) {
+ cell.textLabel.text=@"Renderer";
+ BOOL pending=selectedMetal!=halo_graphics_metal();
+ cell.detailTextLabel.text=pending?@"Relaunch the app to use this renderer.":(halo_graphics_metal()?@"Metal via ANGLE. Changes apply after relaunch.":@"OpenGL ES. Changes apply after relaunch.");
+ UISegmentedControl *choice=[[UISegmentedControl alloc]initWithItems:@[@"OpenGL",@"Metal"]];
+ choice.selectedSegmentIndex=selectedMetal?1:0;
+ [choice addTarget:self action:@selector(rendererChanged:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=choice;
+ } else if(selectedMetal && path.row==1) {
+ UISwitch *toggle=[UISwitch new];toggle.tag=0;
+ toggle.enabled=halo_graphics_metal() && halo_metalfx_supported();toggle.on=halo_metalfx_enabled();
+ cell.textLabel.text=@"MetalFX spatial upscaling";
+ cell.detailTextLabel.text=toggle.enabled?@"Render at up to 720p, then upscale with MetalFX.":(!halo_graphics_metal()?@"Select Metal and relaunch to enable.":@"Unavailable on this device.");
+ [toggle addTarget:self action:@selector(changed:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;
+ } else {
+ NSInteger action=path.row-(selectedMetal?2:1);
+ if(action==0) {
+ UISwitch *toggle=[UISwitch new];toggle.tag=1;toggle.on=atomic_load(&recording);
+ cell.textLabel.text=@"Record first-use GPU stalls";cell.detailTextLabel.text=@"Compile/link stalls are always recorded. This also times first draws and adds GPU synchronization overhead.";
+ [toggle addTarget:self action:@selector(changed:) forControlEvents:UIControlEventValueChanged];cell.accessoryView=toggle;
+ } else if(action==1){cell.textLabel.text=@"Share shader report…";cell.detailTextLabel.text=@"Choose AirDrop in the share sheet to send it to your Mac.";}
+ else{cell.textLabel.text=@"Clear recorded events";cell.detailTextLabel.text=[NSString stringWithFormat:@"%lu events; %lu dropped",(unsigned long)events.count,(unsigned long)dropped];}
+ }
+ return cell;
+}
+- (void)networkChanged:(UISwitch *)sender {[NSUserDefaults.standardUserDefaults setBool:sender.on forKey:@"HaloBrowserRooms"];}
+- (void)rendererChanged:(UISegmentedControl *)sender {
+ halo_graphics_set_preference(sender.selectedSegmentIndex==1);[self.tableView reloadData];
+}
+- (void)changed:(UISwitch *)sender {
+ if(!sender.tag)halo_metalfx_set_enabled(sender.on);else {atomic_store(&recording,sender.on);[drawnPrograms removeAllObjects];}
+ [NSUserDefaults.standardUserDefaults setBool:sender.on forKey:sender.tag?@"HaloRecordShaderStalls":@"HaloMetalFX"];
+}
+- (void)tableView:(UITableView *)table didSelectRowAtIndexPath:(NSIndexPath *)path {
+ [table deselectRowAtIndexPath:path animated:YES];
+ NSInteger action=path.row-(halo_graphics_prefer_metal()?2:1);
+ if(action==1)exportReport(self);
+ else if(action==2){[events removeAllObjects];[drawnPrograms removeAllObjects];dropped=0;[table reloadData];}
+}
+@end
+void halo_debug_present(void) {
+ if(debugController)return;prepare();
+ UIWindow *window=nil;for(UIScene *scene in UIApplication.sharedApplication.connectedScenes)if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *w in ((UIWindowScene*)scene).windows)if(w.isKeyWindow)window=w;
+ UIViewController *presenter=window.rootViewController;
+ while(presenter.presentedViewController)presenter=presenter.presentedViewController;
+ if(!presenter)return;
+ host_ios_touch_reset();HaloDebugController *controller=[[HaloDebugController alloc]initWithStyle:UITableViewStyleInsetGrouped];
+ UINavigationController *navigation=[[UINavigationController alloc]initWithRootViewController:controller];
+ navigation.modalPresentationStyle=UIModalPresentationPageSheet;debugController=navigation;
+ [presenter presentViewController:navigation animated:YES completion:nil];
+ navigation.presentationController.delegate=controller;
+}
+
+#endif
+#if HALO_MACOS
+@interface HaloDebugActions : NSWindowController
+@property NSButton *upscaleButton;
+@property NSButton *recordButton;
+@property NSTextField *eventCount;
+@end
+@implementation HaloDebugActions
+- (instancetype)init {
+ NSWindow *window=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,480,360)
+ styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable backing:NSBackingStoreBuffered defer:NO];
+ self=[super initWithWindow:window];
+ if(self) {
+ window.title=@"Settings";window.releasedWhenClosed=NO;window.delegate=self;
+ NSStackView *stack=[NSStackView new];stack.orientation=NSUserInterfaceLayoutOrientationVertical;
+ stack.alignment=NSLayoutAttributeLeading;stack.spacing=14;stack.translatesAutoresizingMaskIntoConstraints=NO;
+ [window.contentView addSubview:stack];
+ [NSLayoutConstraint activateConstraints:@[
+ [stack.leadingAnchor constraintEqualToAnchor:window.contentView.leadingAnchor constant:24],
+ [stack.trailingAnchor constraintEqualToAnchor:window.contentView.trailingAnchor constant:-24],
+ [stack.topAnchor constraintEqualToAnchor:window.contentView.topAnchor constant:24],
+ [stack.bottomAnchor constraintLessThanOrEqualToAnchor:window.contentView.bottomAnchor constant:-24]]];
+ [stack addArrangedSubview:[NSTextField labelWithString:@"Renderer: Metal (ANGLE)"]];
+ self.upscaleButton=[NSButton checkboxWithTitle:@"MetalFX spatial upscaling" target:self action:@selector(upscale:)];
+ [stack addArrangedSubview:self.upscaleButton];
+ self.recordButton=[NSButton checkboxWithTitle:@"Record first-use GPU stalls" target:self action:@selector(record:)];
+ [stack addArrangedSubview:self.recordButton];
+ NSTextField *note=[NSTextField wrappingLabelWithString:@"Compile/link stalls are recorded automatically. First-use GPU recording adds synchronization overhead; disable it for normal play."];
+ [stack addArrangedSubview:note];[note.widthAnchor constraintEqualToAnchor:stack.widthAnchor].active=YES;
+ NSButton *rooms=[NSButton checkboxWithTitle:@"Browser rooms (experimental; relaunch required)" target:self action:@selector(network:)];
+ rooms.state=[NSUserDefaults.standardUserDefaults boolForKey:@"HaloBrowserRooms"]?NSControlStateValueOn:NSControlStateValueOff;
+ [stack addArrangedSubview:rooms];
+ [stack addArrangedSubview:[NSButton buttonWithTitle:@"AirDrop shader report…" target:self action:@selector(share:)]];
+ [stack addArrangedSubview:[NSButton buttonWithTitle:@"Clear recorded events" target:self action:@selector(clear:)]];
+ self.eventCount=[NSTextField labelWithString:@""];[stack addArrangedSubview:self.eventCount];
+ [window center];
+ }
+ return self;
+}
+-(void)refresh {
+ self.upscaleButton.enabled=halo_metalfx_supported();self.upscaleButton.state=halo_metalfx_enabled();
+ self.recordButton.state=atomic_load(&recording);
+ self.eventCount.stringValue=[NSString stringWithFormat:@"%lu events; %lu dropped",(unsigned long)events.count,(unsigned long)dropped];
+}
+-(void)openSettings:(id)sender {
+ (void)sender;[self refresh];debugPresented=YES;[self showWindow:nil];[self.window makeKeyAndOrderFront:nil];
+ NSString *capture=NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SETTINGS_IMAGE"];
+ if(capture.length) {
+ NSView *view=self.window.contentView;[view layoutSubtreeIfNeeded];
+ NSBitmapImageRep *bitmap=[view bitmapImageRepForCachingDisplayInRect:view.bounds];
+ [view cacheDisplayInRect:view.bounds toBitmapImageRep:bitmap];
+ [[bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}]writeToFile:capture atomically:YES];
+ }
+}
+-(void)windowWillClose:(NSNotification *)notification {(void)notification;debugPresented=NO;}
+-(void)network:(NSButton *)item {[NSUserDefaults.standardUserDefaults setBool:item.state==NSControlStateValueOn forKey:@"HaloBrowserRooms"];}
+-(void)upscale:(NSButton *)item {BOOL on=item.state==NSControlStateValueOn;halo_metalfx_set_enabled(on);[NSUserDefaults.standardUserDefaults setBool:on forKey:@"HaloMetalFX"];[self refresh];}
+-(void)record:(NSButton *)item {BOOL on=item.state==NSControlStateValueOn;atomic_store(&recording,on);[drawnPrograms removeAllObjects];[NSUserDefaults.standardUserDefaults setBool:on forKey:@"HaloRecordShaderStalls"];[self refresh];}
+-(void)share:(id)sender {(void)sender;exportReport(nil);}
+-(void)clear:(id)sender {(void)sender;[events removeAllObjects];[drawnPrograms removeAllObjects];dropped=0;[self refresh];}
+@end
+static HaloDebugActions *actions;
+void halo_debug_install_menu(void) {
+ if(actions)return;prepare();actions=[HaloDebugActions new];
+ NSMenu *menu=NSApp.mainMenu.itemArray.firstObject.submenu;
+ if(!menu)return;
+ /* Reuse SDL's standard Preferences placeholder. AppKit reserves Cmd+,
+ for that item and strips it from duplicate Settings entries. */
+ NSMenuItem *item=nil;
+ for(NSMenuItem *candidate in menu.itemArray)if([candidate.keyEquivalent isEqualToString:@","]){item=candidate;break;}
+ if(!item){item=[[NSMenuItem alloc]initWithTitle:@"Settings…" action:NULL keyEquivalent:@","];[menu insertItem:item atIndex:MIN(2,menu.numberOfItems)];}
+ item.title=@"Settings…";item.action=@selector(openSettings:);item.target=actions;
+}
+void halo_debug_present(void) {halo_debug_install_menu();[actions openSettings:nil];}
+#endif
+
+/* Developer regression: compile every source in a received report using the
+ actual bundled driver. No game state, textures or buffers are changed. */
+void halo_debug_replay_shader_report(void) {
+#if HALO_MACOS
+ NSString *path=NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SHADER_REPORT"];
+ if(!path.length)return;
+ NSData *data=[NSData dataWithContentsOfFile:path];
+ NSDictionary *report=data?[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]:nil;
+ NSDictionary *entries=[report isKindOfClass:NSDictionary.class]?report[@"sources"]:nil;
+ if(![entries isKindOfClass:NSDictionary.class] || entries.count>4096)host_fatal("Invalid shader replay report");
+ __typeof__(&glCreateShader) create=halo_graphics_proc("glCreateShader");
+ __typeof__(&glShaderSource) source=halo_graphics_proc("glShaderSource");
+ __typeof__(&glCompileShader) compile=halo_graphics_proc("glCompileShader");
+ __typeof__(&glDeleteShader) destroy=halo_graphics_proc("glDeleteShader");
+ __typeof__(&glGetShaderInfoLog) getLog=halo_graphics_proc("glGetShaderInfoLog");
+ unsigned passed=0,failed=0;
+ for(NSString *key in entries) {
+ NSDictionary *entry=entries[key];
+ if(![entry isKindOfClass:NSDictionary.class])host_fatal("Invalid shader replay entry");
+ NSString *text=entry[@"source"],*kind=entry[@"type"];
+ if(![text isKindOfClass:NSString.class] || text.length>1024*1024 ||
+ (![kind isEqualToString:@"vertex"] && ![kind isEqualToString:@"fragment"]))host_fatal("Invalid shader replay entry");
+ GLuint shader=create([kind isEqualToString:@"vertex"]?GL_VERTEX_SHADER:GL_FRAGMENT_SHADER);
+ const char *utf8=text.UTF8String;source(shader,1,&utf8,NULL);compile(shader);
+ GLint ok=0;glGetShaderiv(shader,GL_COMPILE_STATUS,&ok);
+ if(ok)passed++;else {char log[4096];getLog(shader,sizeof(log),NULL,log);host_logf(HOST_LOG_ERROR,"Shader replay %s failed: %s",key.UTF8String,log);failed++;}
+ destroy(shader);
+ }
+ host_logf(HOST_LOG_INFO,"Shader replay: %u compiled, %u failed",passed,failed);
+#endif
+}
+
+void halo_debug_test_export(void) {
+#if HALO_MACOS
+ if(NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_SHADER_EXPORT"].length)exportReport(nil);
+#endif
+}
diff --git a/port/ios/host/host_gl.c b/port/ios/host/host_gl.c
new file mode 100644
index 0000000000..1522cda40f
--- /dev/null
+++ b/port/ios/host/host_gl.c
@@ -0,0 +1,37 @@
+#include "host_graphics.h"
+/* GLES services whose pointers or state cannot cross the 32-bit guest ABI. */
+#include "ios_host.h"
+#include
+#include
+#include
+#define GL_FUNCTION(ret,name,args) static ret (GL_APIENTRY *name) args; if(!name)name=(void*)halo_graphics_proc(#name)
+void host_gl_get_string(uint32_t name,int index,char *buffer,uint32_t size) {
+ GL_FUNCTION(const GLubyte *,glGetString,(GLenum));
+ GL_FUNCTION(const GLubyte *,glGetStringi,(GLenum,GLuint));
+ const GLubyte *text=index<0?glGetString(name):glGetStringi(name,index);
+ if(size)SDL_strlcpy(buffer,text?(const char *)text:"",size);
+}
+int host_gl_has_extension(const char *name) {return halo_graphics_has_extension(name);}
+uint32_t host_gl_read_buffer_word(uint32_t buffer,uint32_t offset) {
+ GL_FUNCTION(void,glBindBuffer,(GLenum,GLuint));GL_FUNCTION(void *,glMapBufferRange,(GLenum,GLintptr,GLsizeiptr,GLbitfield));
+ GL_FUNCTION(GLboolean,glUnmapBuffer,(GLenum));GL_FUNCTION(void,glGetIntegerv,(GLenum,GLint *));
+ GLint previous;uint32_t value=0;glGetIntegerv(GL_COPY_READ_BUFFER_BINDING,&previous);glBindBuffer(GL_COPY_READ_BUFFER,buffer);
+ void *p=glMapBufferRange(GL_COPY_READ_BUFFER,offset,4,GL_MAP_READ_BIT);if(p){memcpy(&value,p,4);glUnmapBuffer(GL_COPY_READ_BUFFER);}
+ glBindBuffer(GL_COPY_READ_BUFFER,previous);return value;
+}
+static GLsync fences[8];
+void host_gl_fence_frame(uint32_t slot) {
+ GL_FUNCTION(void,glDeleteSync,(GLsync));GL_FUNCTION(GLsync,glFenceSync,(GLenum,GLbitfield));
+ if(slot>=8)return;if(fences[slot])glDeleteSync(fences[slot]);fences[slot]=glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE,0);
+}
+void host_gl_wait_frame(uint32_t slot) {
+ GL_FUNCTION(void,glDeleteSync,(GLsync));GL_FUNCTION(GLenum,glClientWaitSync,(GLsync,GLbitfield,GLuint64));
+ if(slot>=8 || !fences[slot])return;glClientWaitSync(fences[slot],GL_SYNC_FLUSH_COMMANDS_BIT,1000000000ull);
+ glDeleteSync(fences[slot]);fences[slot]=NULL;
+}
+void host_gl_buffer_write(uint32_t target,uint32_t offset,uint32_t size,const void *data) {
+ GL_FUNCTION(void *,glMapBufferRange,(GLenum,GLintptr,GLsizeiptr,GLbitfield));
+ GL_FUNCTION(GLboolean,glUnmapBuffer,(GLenum));GL_FUNCTION(void,glBufferSubData,(GLenum,GLintptr,GLsizeiptr,const void *));
+ void *p=glMapBufferRange(target,offset,size,GL_MAP_WRITE_BIT|GL_MAP_UNSYNCHRONIZED_BIT|GL_MAP_INVALIDATE_RANGE_BIT);
+ if(p){memcpy(p,data,size);glUnmapBuffer(target);}else glBufferSubData(target,offset,size,data);
+}
diff --git a/port/ios/host/host_gl_dispatch.h b/port/ios/host/host_gl_dispatch.h
new file mode 100644
index 0000000000..a3c866f36a
--- /dev/null
+++ b/port/ios/host/host_gl_dispatch.h
@@ -0,0 +1,18 @@
+/* Include after GL declarations. Driver choice is fixed for the process. */
+#pragma once
+#include "host_graphics.h"
+#define HALO_GL_PROC(name) ({static __typeof__(&name) fn; if(!fn)fn=(__typeof__(&name))halo_graphics_proc(#name); fn;})
+#define glBindFramebuffer HALO_GL_PROC(glBindFramebuffer)
+#define glBindTexture HALO_GL_PROC(glBindTexture)
+#define glBlitFramebuffer HALO_GL_PROC(glBlitFramebuffer)
+#define glCheckFramebufferStatus HALO_GL_PROC(glCheckFramebufferStatus)
+#define glDisable HALO_GL_PROC(glDisable)
+#define glEnable HALO_GL_PROC(glEnable)
+#define glFinish HALO_GL_PROC(glFinish)
+#define glFramebufferTexture2D HALO_GL_PROC(glFramebufferTexture2D)
+#define glGenFramebuffers HALO_GL_PROC(glGenFramebuffers)
+#define glGetIntegerv HALO_GL_PROC(glGetIntegerv)
+#define glGetProgramiv HALO_GL_PROC(glGetProgramiv)
+#define glGetShaderiv HALO_GL_PROC(glGetShaderiv)
+#define glGetString HALO_GL_PROC(glGetString)
+#define glIsEnabled HALO_GL_PROC(glIsEnabled)
diff --git a/port/ios/host/host_graphics.h b/port/ios/host/host_graphics.h
new file mode 100644
index 0000000000..3aaf4740a7
--- /dev/null
+++ b/port/ios/host/host_graphics.h
@@ -0,0 +1,18 @@
+#pragma once
+#include
+struct SDL_Window;
+void halo_graphics_initialize(void);
+int halo_graphics_metal(void);
+int halo_graphics_prefer_metal(void);
+void halo_graphics_set_preference(int metal);
+void *halo_graphics_proc(const char *name);
+int halo_graphics_has_extension(const char *name);
+void *halo_graphics_create(struct SDL_Window *window);
+int halo_graphics_make_current(void);
+int halo_graphics_swap(void);
+int halo_graphics_swap_interval(int interval);
+/* IOSurface ownership is transferred to/from ANGLE around each MetalFX copy. */
+unsigned int halo_graphics_shared_create(void *iosurface,unsigned int width,unsigned int height);
+int halo_graphics_shared_begin(void);
+int halo_graphics_shared_end(void);
+void halo_graphics_shared_destroy(void);
diff --git a/port/ios/host/host_graphics.m b/port/ios/host/host_graphics.m
new file mode 100644
index 0000000000..3ca80b851a
--- /dev/null
+++ b/port/ios/host/host_graphics.m
@@ -0,0 +1,132 @@
+/* ANGLE's Metal backend and Apple's native GLES driver have distinct symbol
+ namespaces. No guest rendering call may accidentally cross between them. */
+#if HALO_MACOS
+#import
+#else
+#import
+#endif
+#import
+#include
+#include
+#define EGL_EGL_PROTOTYPES 0
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "host_graphics.h"
+#include "host_shader_cache.h"
+#include "host_debug.h"
+#include "ios_host.h"
+
+static BOOL metal;
+static void *eglLibrary,*glesLibrary;
+static EGLDisplay display;
+static EGLConfig config;
+static EGLContext context;
+static EGLSurface windowSurface,sharedSurface;
+static GLuint sharedTexture;
+static BOOL sharedBound;
+static SDL_MetalView metalView;
+static PFNEGLGETPROCADDRESSPROC eglGetProcAddress;static PFNEGLINITIALIZEPROC eglInitialize;static PFNEGLCHOOSECONFIGPROC eglChooseConfig;
+static PFNEGLCREATECONTEXTPROC eglCreateContext;static PFNEGLCREATEWINDOWSURFACEPROC eglCreateWindowSurface;static PFNEGLMAKECURRENTPROC eglMakeCurrent;
+static PFNEGLSWAPBUFFERSPROC eglSwapBuffers;static PFNEGLSWAPINTERVALPROC eglSwapInterval;static PFNEGLGETERRORPROC eglGetError;
+static PFNEGLCREATEPBUFFERFROMCLIENTBUFFERPROC eglCreatePbufferFromClientBuffer;static PFNEGLBINDTEXIMAGEPROC eglBindTexImage;
+static PFNEGLRELEASETEXIMAGEPROC eglReleaseTexImage;static PFNEGLDESTROYSURFACEPROC eglDestroySurface;
+
+int halo_graphics_prefer_metal(void) {
+ NSString *value=[NSUserDefaults.standardUserDefaults stringForKey:@"HaloRenderer"];
+ return ![value isEqualToString:@"opengl"];
+}
+void halo_graphics_set_preference(int value) {
+ [NSUserDefaults.standardUserDefaults setObject:value?@"metal":@"opengl" forKey:@"HaloRenderer"];
+}
+void halo_graphics_initialize(void) {
+ metal=halo_graphics_prefer_metal();
+#if HALO_MACOS
+ metal=YES; /* macOS package ships ANGLE Metal; Apple has no GLES driver. */
+#endif
+#if !HALO_MACOS
+ NSString *test=NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_RENDERER"];
+ if([test isEqualToString:@"metal"] || [test isEqualToString:@"opengl"])metal=[test isEqualToString:@"metal"];
+#endif
+ host_logf(HOST_LOG_INFO,"Renderer: %s",metal?"Metal (ANGLE)":"OpenGL ES (Apple)");
+}
+int halo_graphics_metal(void) {return metal;}
+void *halo_graphics_proc(const char *name) {
+ void *result=metal?(void *)eglGetProcAddress(name):(void *)SDL_GL_GetProcAddress(name);
+ if(!result)host_fatal("Graphics entry point unavailable: %s",name);
+ return result;
+}
+int halo_graphics_has_extension(const char *name) {
+ if(!metal)return SDL_GL_ExtensionSupported(name);
+ const GLubyte *(*getStringi)(GLenum,GLuint)=halo_graphics_proc("glGetStringi");
+ void (*getInteger)(GLenum,GLint *)=halo_graphics_proc("glGetIntegerv");
+ GLint count=0;getInteger(GL_NUM_EXTENSIONS,&count);
+ for(GLint i=0;i
+#import
+#include "ios_host.h"
+#include "xiso.h"
+#import "../../apple/game_store.h"
+#import "host_orientation.h"
+
+#if !__has_feature(objc_arc)
+#error The asynchronous importer requires Objective-C ARC.
+#endif
+
+@interface HaloImportController : HaloLandscapeController
+@property(nonatomic, copy) NSString *documents;
+@property(nonatomic, strong) UILabel *statusLabel;
+@property(nonatomic, strong) UIButton *chooseButton;
+@property(nonatomic, strong) UIButton *cancelButton;
+@property(nonatomic, strong) UIProgressView *progress;
+@property(nonatomic, strong) UIActivityIndicatorView *spinner;
+@property(nonatomic, strong) NSURL *pendingImage;
+@property(atomic) BOOL cancelled;
+@property(atomic, strong) NSFileCoordinator *coordinator;
+@property(nonatomic) BOOL importing;
+@property(nonatomic) BOOL ready;
+@property(nonatomic) UIBackgroundTaskIdentifier backgroundTask;
+@property(nonatomic) double lastProgressTime; /* extraction thread only */
+@end
+
+static int import_progress(void *context, const char *file, uint64_t done, uint64_t total) {
+ HaloImportController *controller=(__bridge HaloImportController *)context;
+ if (controller.cancelled) return 0;
+ double now=NSProcessInfo.processInfo.systemUptime;
+ if (done!=total && done && now-controller.lastProgressTime<0.1) return 1;
+ controller.lastProgressTime=now;
+ NSString *name=[NSString stringWithUTF8String:file];
+ float fraction=total?(float)((double)done/total):0;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ controller.progress.hidden=NO;
+ [controller.spinner stopAnimating];
+ controller.progress.progress=fraction;
+ controller.statusLabel.text=[NSString stringWithFormat:@"Importing %@ · %.0f%%",name,fraction*100];
+ });
+ return 1;
+}
+
+@implementation HaloImportController
+- (void)viewDidLoad {
+ [super viewDidLoad];
+ self.backgroundTask=UIBackgroundTaskInvalid;
+ self.view.backgroundColor=[UIColor colorWithRed:0.025 green:0.045 blue:0.055 alpha:1];
+ self.overrideUserInterfaceStyle=UIUserInterfaceStyleDark;
+ UIScrollView *scroll=[UIScrollView new];scroll.translatesAutoresizingMaskIntoConstraints=NO;
+ [self.view addSubview:scroll];
+ UIView *content=[UIView new];content.translatesAutoresizingMaskIntoConstraints=NO;[scroll addSubview:content];
+ UIStackView *stack=[UIStackView new];stack.axis=UILayoutConstraintAxisVertical;stack.spacing=14;
+ stack.alignment=UIStackViewAlignmentCenter;stack.translatesAutoresizingMaskIntoConstraints=NO;[content addSubview:stack];
+ UIImageView *icon=[[UIImageView alloc] initWithImage:[UIImage imageNamed:@"RingArtwork"]];
+ icon.contentMode=UIViewContentModeScaleAspectFill;icon.clipsToBounds=YES;icon.layer.cornerRadius=16;
+ [icon.widthAnchor constraintEqualToConstant:72].active=YES;[icon.heightAnchor constraintEqualToConstant:72].active=YES;
+ [stack addArrangedSubview:icon];
+ UILabel *title=[UILabel new];title.text=@"Halo: CE";title.font=[UIFont preferredFontForTextStyle:UIFontTextStyleLargeTitle];
+ title.adjustsFontForContentSizeCategory=YES;title.textAlignment=NSTextAlignmentCenter;[stack addArrangedSubview:title];
+ UILabel *body=[UILabel new];body.text=@"Choose your own Halo: Combat Evolved Xbox XISO.\nWe'll verify and keep a private copy, then start the game.";
+ body.font=[UIFont preferredFontForTextStyle:UIFontTextStyleBody];body.adjustsFontForContentSizeCategory=YES;
+ body.numberOfLines=0;body.textAlignment=NSTextAlignmentCenter;body.textColor=UIColor.secondaryLabelColor;[stack addArrangedSubview:body];
+ self.chooseButton=[UIButton buttonWithType:UIButtonTypeSystem];
+ UIButtonConfiguration *style=UIButtonConfiguration.filledButtonConfiguration;
+ style.title=@"Choose Halo ISO";style.baseBackgroundColor=UIColor.systemBlueColor;
+ style.baseForegroundColor=UIColor.whiteColor;style.cornerStyle=UIButtonConfigurationCornerStyleLarge;
+ style.contentInsets=NSDirectionalEdgeInsetsMake(14,24,14,24);self.chooseButton.configuration=style;
+ self.chooseButton.accessibilityIdentifier=@"chooseXISO";
+ [self.chooseButton addTarget:self action:@selector(chooseImage) forControlEvents:UIControlEventTouchUpInside];[stack addArrangedSubview:self.chooseButton];
+ self.statusLabel=[UILabel new];self.statusLabel.font=[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
+ self.statusLabel.adjustsFontForContentSizeCategory=YES;self.statusLabel.numberOfLines=0;self.statusLabel.textAlignment=NSTextAlignmentCenter;
+ self.statusLabel.text=@"Original Xbox PAL or NTSC-US · .iso or .xiso\nNo game files are included.";
+ self.statusLabel.accessibilityIdentifier=@"importStatus";[stack addArrangedSubview:self.statusLabel];
+ self.spinner=[[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
+ self.spinner.hidesWhenStopped=YES;[stack addArrangedSubview:self.spinner];
+ self.progress=[[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];self.progress.hidden=YES;
+ self.progress.accessibilityLabel=@"Game import progress";[stack addArrangedSubview:self.progress];
+ [self.progress.widthAnchor constraintEqualToAnchor:stack.widthAnchor].active=YES;
+ self.cancelButton=[UIButton buttonWithType:UIButtonTypeSystem];[self.cancelButton setTitle:@"Cancel import" forState:UIControlStateNormal];
+ self.cancelButton.accessibilityIdentifier=@"cancelImport";
+ [self.cancelButton addTarget:self action:@selector(cancelImport) forControlEvents:UIControlEventTouchUpInside];
+ self.cancelButton.hidden=YES;[stack addArrangedSubview:self.cancelButton];
+ NSLayoutConstraint *width=[stack.widthAnchor constraintEqualToAnchor:content.widthAnchor constant:-48];width.priority=999;
+ [NSLayoutConstraint activateConstraints:@[
+ [scroll.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
+ [scroll.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
+ [scroll.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],[scroll.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
+ [content.topAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.topAnchor],[content.bottomAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.bottomAnchor],
+ [content.leadingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.leadingAnchor],[content.trailingAnchor constraintEqualToAnchor:scroll.contentLayoutGuide.trailingAnchor],
+ [content.widthAnchor constraintEqualToAnchor:scroll.frameLayoutGuide.widthAnchor],[content.heightAnchor constraintGreaterThanOrEqualToAnchor:scroll.frameLayoutGuide.heightAnchor],
+ [stack.centerXAnchor constraintEqualToAnchor:content.centerXAnchor],[stack.centerYAnchor constraintEqualToAnchor:content.centerYAnchor],
+ [stack.topAnchor constraintGreaterThanOrEqualToAnchor:content.topAnchor constant:24],
+ [stack.bottomAnchor constraintLessThanOrEqualToAnchor:content.bottomAnchor constant:-24],
+ [stack.widthAnchor constraintLessThanOrEqualToConstant:520],width]];
+}
+- (void)viewDidAppear:(BOOL)animated {
+ [super viewDidAppear:animated];
+ if (self.pendingImage && !self.importing) {NSURL *url=self.pendingImage;self.pendingImage=nil;[self importImage:url];}
+}
+- (void)chooseImage {
+ if (self.importing) return;
+ /* Some Files providers classify .xiso as generic data. Validate the file's
+ contents ourselves, allowing both that type and normal ISO disk images. */
+ UIDocumentPickerViewController *picker=[[HaloLandscapeDocumentPicker alloc] initForOpeningContentTypes:@[UTTypeData] asCopy:NO];
+ picker.delegate=self;picker.allowsMultipleSelection=NO;
+ [self presentViewController:picker animated:YES completion:nil];
+}
+- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls {
+ (void)controller;if (urls.count) [self importImage:urls.firstObject];
+}
+- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
+ (void)controller;self.statusLabel.text=@"Choose your Halo XISO whenever you're ready.";
+}
+- (void)cancelImport {self.cancelled=YES;[self.coordinator cancel];self.cancelButton.enabled=NO;self.statusLabel.text=@"Cancelling import…";}
+- (void)importImage:(NSURL *)url {
+ if (self.importing) return;
+ self.importing=YES;self.cancelled=NO;self.lastProgressTime=0;
+ self.chooseButton.hidden=YES;self.cancelButton.hidden=NO;self.cancelButton.enabled=YES;
+ self.progress.hidden=YES;self.progress.progress=0;[self.spinner startAnimating];
+ self.statusLabel.text=@"Opening disc image… If it's in iCloud, it may need to download first.";
+ BOOL scoped=[url startAccessingSecurityScopedResource];
+ self.backgroundTask=[UIApplication.sharedApplication beginBackgroundTaskWithName:@"Import Halo" expirationHandler:^{self.cancelled=YES;[self.coordinator cancel];}];
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED,0), ^{
+ @autoreleasepool {
+ __block NSString *failure=nil;__block BOOL imported=NO;
+ NSFileCoordinator *coordinator=[[NSFileCoordinator alloc] initWithFilePresenter:nil];NSError *coordinationError=nil;
+ self.coordinator=coordinator;
+ if(self.cancelled)[coordinator cancel];
+ [coordinator coordinateReadingItemAtURL:url options:0 error:&coordinationError byAccessor:^(NSURL *readable) {
+ if(self.cancelled){failure=@"Import cancelled.";return;}
+ imported=halo_game_store_import(readable,self.documents,import_progress,(__bridge void *)self,&failure);
+ }];
+ if(coordinationError && !imported)failure=self.cancelled?@"Import cancelled.":@"Could not access the image. Download it in Files, then choose it again.";
+ self.coordinator=nil;
+ if (scoped) [url stopAccessingSecurityScopedResource];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self.spinner stopAnimating];self.cancelButton.hidden=YES;self.importing=NO;
+ if (self.backgroundTask!=UIBackgroundTaskInvalid) { [UIApplication.sharedApplication endBackgroundTask:self.backgroundTask];self.backgroundTask=UIBackgroundTaskInvalid; }
+ if (imported) {self.statusLabel.text=@"Ready. Starting Halo…";self.ready=YES;}
+ else {self.statusLabel.text=failure?:@"Could not import this disc image.";self.chooseButton.hidden=NO;self.progress.hidden=YES;}
+ });
+ }
+ });
+}
+@end
+
+void host_ios_prepare_assets(const char *root) {
+ NSString *documents=[NSString stringWithUTF8String:root];
+ NSFileManager *files=NSFileManager.defaultManager;
+ NSString *maps=[documents stringByAppendingPathComponent:@"maps"];
+ /* Recover only directories carrying our own marker. Interrupted copies
+ can be discarded; a displaced maps folder is restored before retrying. */
+ for (NSString *name in [files contentsOfDirectoryAtPath:documents error:nil]) {
+ if (![name hasPrefix:@".halo-import-"] || ![[NSUUID alloc] initWithUUIDString:[name substringFromIndex:13]]) continue;
+ NSString *staging=[documents stringByAppendingPathComponent:name];
+ if (![[NSString stringWithContentsOfFile:[staging stringByAppendingPathComponent:@"owner.txt"] encoding:NSUTF8StringEncoding error:nil] isEqualToString:@"Halo XISO import v1"]) continue;
+ NSString *backup=[staging stringByAppendingPathComponent:@"previous-maps"];
+ if ([files fileExistsAtPath:backup]) {
+ if (![files fileExistsAtPath:maps]) [files moveItemAtPath:backup toPath:maps error:nil];
+ if ([files fileExistsAtPath:backup] && !xiso_maps_ready(maps.fileSystemRepresentation,NULL,0)) continue;
+ }
+ [files removeItemAtPath:staging error:nil];
+ }
+ if (halo_game_store_current(documents)) return;
+ UIWindowScene *scene=nil;
+ for (UIScene *candidate in UIApplication.sharedApplication.connectedScenes)
+ if ([candidate isKindOfClass:UIWindowScene.class]) {scene=(UIWindowScene *)candidate;break;}
+ if (!scene) host_fatal("Could not open the game import screen.");
+ HaloImportController *controller=[HaloImportController new];controller.documents=documents;
+ /* Dropping an XISO into the app with Finder/Files also works. Only choose
+ automatically when exactly one image is present; never alter the source. */
+ NSMutableArray *images=[NSMutableArray new];
+ for (NSString *directory in @[documents,[documents stringByAppendingPathComponent:@"Inbox"]]) {
+ for (NSURL *url in [files contentsOfDirectoryAtURL:[NSURL fileURLWithPath:directory] includingPropertiesForKeys:@[NSURLIsRegularFileKey] options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]) {
+ NSString *extension=url.pathExtension.lowercaseString;NSNumber *regular=nil;[url getResourceValue:®ular forKey:NSURLIsRegularFileKey error:nil];
+ if (regular.boolValue && ([extension isEqualToString:@"iso"] || [extension isEqualToString:@"xiso"])) [images addObject:url];
+ }
+ }
+ if (images.count==1) controller.pendingImage=images.firstObject;
+ UIWindow *window=[[UIWindow alloc] initWithWindowScene:scene];window.frame=scene.coordinateSpace.bounds;
+ window.windowLevel=UIWindowLevelNormal+2;window.rootViewController=controller;[window makeKeyAndVisible];
+ while (!controller.ready) {
+ @autoreleasepool {
+ /* Like SDL's UIKit event pump, service tracking as well as the
+ default mode so controls inside the scroll view receive taps. */
+ CFRunLoopRunInMode(kCFRunLoopDefaultMode,0.01,true);
+ CFRunLoopRunInMode((__bridge CFStringRef)UITrackingRunLoopMode,0.01,true);
+ }
+ }
+ window.hidden=YES;window.rootViewController=nil;
+}
diff --git a/port/ios/host/host_loader.c b/port/ios/host/host_loader.c
new file mode 100644
index 0000000000..1b47779f8a
--- /dev/null
+++ b/port/ios/host/host_loader.c
@@ -0,0 +1,38 @@
+/* Alias signed code pages into the arena; writable game data has its own pages. */
+#include "ios_host.h"
+#include "guest_image.h"
+#include
+#include
+#include
+struct host_guest_image host_image;
+static void missing_import(void) { host_fatal("an unavailable guest import was called"); }
+int host_load_image(const void *unused,size_t ignored) {
+ (void)unused;(void)ignored;
+ if(host_memory_initialize(HALO_GUEST_IMAGE_BASE,IOS_GUEST_IMAGE_END-HALO_GUEST_IMAGE_BASE))return -1;
+ vm_address_t code=(uintptr_t)host_pointer(HALO_GUEST_IMAGE_BASE);
+ vm_prot_t current=0,maximum=0;
+ kern_return_t result=vm_remap(mach_task_self(),&code,IOS_GUEST_CODE_SIZE,0,
+ VM_FLAGS_FIXED|VM_FLAGS_OVERWRITE,mach_task_self(),(vm_address_t)halo_guest_code,
+ FALSE,¤t,&maximum,VM_INHERIT_COPY);
+ if(result || !(current&VM_PROT_EXECUTE) || (current&VM_PROT_WRITE)) {
+ host_logf(HOST_LOG_ERROR,"cannot alias signed guest code: %d (protection %d)",result,current);return -1;
+ }
+ void *data=host_pointer(IOS_GUEST_DATA_ADDRESS);
+ if(mprotect(data,(IOS_GUEST_IMAGE_END-IOS_GUEST_DATA_ADDRESS+0x3fff)&~0x3fff,PROT_READ|PROT_WRITE))return -1;
+ memcpy(data,halo_guest_data,IOS_GUEST_DATA_SIZE);
+ const struct halo_guest_header *header=host_pointer(HALO_GUEST_IMAGE_BASE);
+ if(header->magic!=HALO_GUEST_MAGIC || header->abi_version!=HALO_GUEST_ABI_VERSION)return -1;
+ host_image=(struct host_guest_image){header,HALO_GUEST_IMAGE_BASE,IOS_GUEST_IMAGE_END};
+ uint64_t *table=host_pointer(header->import_table);
+ const char *name=host_pointer(header->import_names);
+ uint32_t count=*(const uint32_t *)host_pointer(header->import_count);
+ if(count>4096)return -1;
+ unsigned missing=0;
+ for(unsigned i=0;i
+#if HALO_MACOS
+#import
+#else
+#import
+#endif
+#include "ios_host.h"
+#include "../network/room_bridge.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static char data_root[1024],save_root[1024];
+static FILE *log_file;
+void host_logf(int priority,const char *format,...) {
+ (void)priority;va_list ap;va_start(ap,format);
+ char text[4096];vsnprintf(text,sizeof(text),format,ap);va_end(ap);
+ fprintf(stderr,"halo-ios: %s\n",text);
+ if(log_file){flockfile(log_file);fprintf(log_file,"%s\n",text);fflush(log_file);funlockfile(log_file);}
+}
+void host_log(int priority,const char *text) { host_logf(priority,"%s",text); }
+void host_fatal(const char *format,...) {
+ va_list ap;va_start(ap,format);char text[1024];vsnprintf(text,sizeof(text),format,ap);va_end(ap);
+ host_logf(HOST_LOG_ERROR,"FATAL: %s",text);
+ SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR,"Halo",text,NULL);exit(1);
+}
+void host_abort(const char *reason) { host_logf(HOST_LOG_ERROR,"guest abort: %s",reason);abort(); }
+void host_exit(int code) {host_logf(HOST_LOG_INFO,"game exit %d",code);exit(code);}
+int host_errno(void) {return host_linux_errno(errno);}
+void host_debug_thread_started(void) {}
+void host_debug_thread_exited(void) {}
+void host_debug_start_sampler(const char *setting) {(void)setting;}
+
+static uint32_t copy_string(const char *text) {
+ char *p=host_low_map(strlen(text)+1,PROT_READ|PROT_WRITE);
+ if(!p)host_fatal("out of guest memory");strcpy(p,text);return guest_pointer(p);
+}
+int main(int argc,char **argv) {
+ (void)argc;(void)argv;
+ @autoreleasepool {
+#if HALO_MACOS
+ SDL_SetAppMetadata("Halo: Combat Evolved",[[NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] UTF8String],"org.haloce.macos");
+ /* SDL must create NSApplication itself. Pre-creating the base AppKit
+ class skips SDL's standard menus and its terminate: -> SDL_QUIT
+ bridge, leaving Dock Quit disconnected from the guest event loop. */
+ if(!SDL_Init(SDL_INIT_VIDEO))host_fatal("SDL video initialization: %s",SDL_GetError());
+ NSString *documents=[NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory,NSUserDomainMask,YES).firstObject stringByAppendingPathComponent:NSBundle.mainBundle.bundleIdentifier];
+ NSString *override=NSProcessInfo.processInfo.environment[@"HALO_MAC_TEST_DATA_ROOT"];
+ if(override.length)documents=override;
+ [NSFileManager.defaultManager createDirectoryAtPath:documents withIntermediateDirectories:YES attributes:nil error:nil];
+#elif TARGET_OS_TV
+ /* tvOS apps may only write to Caches (purgeable). */
+ NSString *documents=[NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask,YES).firstObject stringByAppendingPathComponent:@"Halo"];
+ [NSFileManager.defaultManager createDirectoryAtPath:documents withIntermediateDirectories:YES attributes:nil error:nil];
+#else
+ NSString *documents=NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES).firstObject;
+#endif
+ snprintf(data_root,sizeof(data_root),"%s",documents.fileSystemRepresentation);
+ snprintf(save_root,sizeof(save_root),"%s/save",data_root);mkdir(save_root,0755);
+ chdir(data_root);
+ log_file=fopen("ios-runtime.log","w");setvbuf(stderr,NULL,_IONBF,0);
+ host_logf(HOST_LOG_INFO,"Halo iOS native guest starting");
+#if !HALO_MACOS
+ UIApplication.sharedApplication.idleTimerDisabled=YES;
+#endif
+ host_ios_prepare_assets(data_root);
+ NSString *gameData=halo_game_store_current(documents);
+ if(!gameData)host_fatal("Private game image is unavailable.");
+ snprintf(data_root,sizeof(data_root),"%s",gameData.fileSystemRepresentation);
+ if(host_load_image(NULL,0))host_fatal("Could not map the signed game image. See ios-runtime.log in Files.");
+ host_install_signal_handlers();
+ SDL_SetHint(SDL_HINT_ORIENTATIONS,"LandscapeLeft LandscapeRight");
+ SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS,"0");
+ /* Keep the Siri Remote out of the player ports; Halo needs a real controller. */
+ SDL_SetHint(SDL_HINT_TV_REMOTE_AS_JOYSTICK,"0");
+ if(!SDL_Init(SDL_INIT_VIDEO|SDL_INIT_AUDIO|SDL_INIT_GAMEPAD))host_fatal("SDL initialization: %s",SDL_GetError());
+ host_ios_touch_initialize();
+ halo_graphics_initialize();
+ halo_debug_initialize();
+ host_ios_room_prepare();
+ const SDL_DisplayMode *mode=SDL_GetDesktopDisplayMode(SDL_GetPrimaryDisplay());
+ int width=640,pixel_width=640,pixel_height=480;
+ if(mode && mode->w && mode->h){
+ int longer=mode->w>mode->h?mode->w:mode->h,shorter=mode->w>mode->h?mode->h:mode->w;
+ float density=mode->pixel_density>0?mode->pixel_density:1;
+ width=(480*longer/shorter)&~1;
+ pixel_width=(int)(longer*density+0.5f);pixel_height=(int)(shorter*density+0.5f);
+ }
+ char env_config[1200],env_data[1200],env_save[1200],env_width[64],env_pixel_width[64],env_pixel_height[64];
+ snprintf(env_config,sizeof(env_config),"HALO_CONFIG_ROOT=%s",documents.fileSystemRepresentation);
+ snprintf(env_data,sizeof(env_data),"HALO_DATA_ROOT=%s",data_root);
+ snprintf(env_save,sizeof(env_save),"HALO_SAVE_ROOT=%s",save_root);
+ snprintf(env_width,sizeof(env_width),"HALO_DISPLAY_WIDTH=%d",width);
+ snprintf(env_pixel_width,sizeof(env_pixel_width),"HALO_DISPLAY_PIXEL_WIDTH=%d",pixel_width);
+ snprintf(env_pixel_height,sizeof(env_pixel_height),"HALO_DISPLAY_PIXEL_HEIGHT=%d",pixel_height);
+ const char *env[32];size_t env_count=0;
+ env[env_count++]=env_config;env[env_count++]=env_data;env[env_count++]=env_save;env[env_count++]=env_width;
+ env[env_count++]=env_pixel_width;env[env_count++]=env_pixel_height;
+#if TARGET_OS_TV
+ char env_render[64];
+ /* The build chooses the render height (tools/ios_build.py --render-height). */
+ int render_height=[[NSBundle.mainBundle objectForInfoDictionaryKey:@"HaloRenderHeight"] intValue];
+ if(render_height>0){snprintf(env_render,sizeof(env_render),"HALO_RENDER_HEIGHT=%d",render_height);env[env_count++]=env_render;}
+#endif
+#if HALO_MACOS
+ env[env_count++]="HALO_DESKTOP_INPUT=1";
+#endif
+ halo_browser_rooms=[NSUserDefaults.standardUserDefaults boolForKey:@"HaloBrowserRooms"];
+ if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_ROOM"] length])halo_browser_rooms=1;
+ env[env_count++]=halo_browser_rooms?"HALO_NET_ONLINE=0":"HALO_NET_ONLINE=1";
+ host_logf(HOST_LOG_INFO,"Multiplayer transport: %s",halo_browser_rooms?"WebRTC rooms":"native public servers");
+ /* Explicit launch-only diagnostics, never persisted to a user's settings. */
+ char menu_diagnostic[1024], exit_diagnostic[128];
+ const char *test_menu=getenv("HALO_MENU_OPEN"), *test_exit=getenv("HALO_EXIT_AFTER");
+ if(test_menu && *test_menu) {
+ snprintf(menu_diagnostic,sizeof(menu_diagnostic),"HALO_MENU_OPEN=%s",test_menu);
+ env[env_count++]=menu_diagnostic;
+ host_logf(HOST_LOG_INFO,"Test menu: %s",test_menu);
+ }
+ if(test_exit && *test_exit) {
+ snprintf(exit_diagnostic,sizeof(exit_diagnostic),"HALO_EXIT_AFTER=%s",test_exit);
+ env[env_count++]=exit_diagnostic;
+ host_logf(HOST_LOG_INFO,"Test exit after: %s",test_exit);
+ }
+ env[env_count++]="TZ=UTC0";env[env_count++]=NULL;
+ uint32_t *environment=host_low_map(sizeof(uint32_t)*env_count,PROT_READ|PROT_WRITE);
+ for(size_t i=0;istart,data_root);
+ host_guest_on_stack((uintptr_t)host_pointer(host_image.header->start),guest_pointer(boot),host_arena,(char *)stack+stack_size+0x4000);
+ }
+ return 0;
+}
diff --git a/port/ios/host/host_memory.c b/port/ios/host/host_memory.c
new file mode 100644
index 0000000000..9d51409647
--- /dev/null
+++ b/port/ios/host/host_memory.c
@@ -0,0 +1,190 @@
+/* Reserve an aligned arena and emulate Xbox pages on Apple's 16 KB pages. */
+#include "ios_host.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define PAGE 0x4000u
+#define POOL_START 0x01000000u
+#define POOL_END 0x78000000u
+#define POOL_PAGES ((POOL_END-POOL_START)/PAGE)
+#define WINDOW_PAGES (HALO_GUEST_WINDOW_SIZE/PAGE)
+uintptr_t host_arena;
+static unsigned char allocated[POOL_PAGES];
+static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
+static uint32_t serial = 1;
+static uint32_t generations[WINDOW_PAGES];
+static volatile unsigned char protected_pages[WINDOW_PAGES];
+static unsigned char watched_pages[WINDOW_PAGES];
+static unsigned page_locks[WINDOW_PAGES];
+static int watch_enabled;
+static struct sigaction old_bus, old_segv;
+
+static size_t rounded(size_t n) { return (n + PAGE - 1) & ~(size_t)(PAGE-1); }
+static int in_window(uint32_t p, uint64_t n) {
+ return p >= HALO_GUEST_WINDOW_BASE && (uint64_t)p+n <= (uint64_t)HALO_GUEST_WINDOW_BASE+HALO_GUEST_WINDOW_SIZE;
+}
+
+int host_memory_initialize(uint32_t image_base, uint32_t image_size) {
+ (void)image_base; (void)image_size;
+ vm_address_t arena = 0;
+ kern_return_t result = vm_map(mach_task_self(), &arena, 0x100000000ull, 0xffffffffull,
+ VM_FLAGS_ANYWHERE, MACH_PORT_NULL, 0, FALSE, VM_PROT_NONE, VM_PROT_ALL, VM_INHERIT_NONE);
+ if (result != KERN_SUCCESS || (arena & 0xffffffffull)) {
+ host_logf(HOST_LOG_ERROR,"cannot reserve aligned guest arena: %d",result); return -1;
+ }
+ host_arena = arena;
+ if (mprotect(host_pointer(HALO_GUEST_WINDOW_BASE),HALO_GUEST_WINDOW_SIZE,PROT_READ|PROT_WRITE)) return -1;
+ host_logf(HOST_LOG_INFO,"guest arena at %llx, native pages %d",(unsigned long long)arena,getpagesize());
+ return 0;
+}
+
+void *host_low_map(size_t size, int protection) {
+ size_t pages=rounded(size)/PAGE, run=0;
+ if (!pages || pages>POOL_PAGES) return NULL;
+ pthread_mutex_lock(&lock);
+ for (size_t i=0;iPOOL_END) return;
+ pthread_mutex_lock(&lock);
+ mprotect(address,pages*PAGE,PROT_NONE);
+ madvise(address,pages*PAGE,MADV_DONTNEED);
+ memset(allocated+(p-POOL_START)/PAGE,0,pages);
+ pthread_mutex_unlock(&lock);
+}
+int host_low_owns(uintptr_t p,size_t n) {
+ return host_arena && p>=host_arena && p-host_arena+n<=0x100000000ull;
+}
+
+/* Linux mmap constants are deliberately decoded here instead of using Darwin's. */
+long host_guest_mmap(uint64_t address,uint64_t size,int protection,int flags,int fd,int64_t offset) {
+ if (!size || size>0x70000000ull || address+size>0x100000000ull) return -22;
+ void *p;
+ if (flags & (0x10|0x100000)) {
+ if (!in_window((uint32_t)address,size)) return -22;
+ p=host_pointer(address);
+ uint32_t first=(uint32_t)address & ~(PAGE-1);
+ size_t length=rounded(address+size-first);
+ if (mprotect(host_pointer(first),length,PROT_READ|PROT_WRITE)) return -12;
+ memset(p,0,size);
+ } else {
+ p=host_low_map(size,PROT_READ|PROT_WRITE);
+ if (!p) return -12;
+ }
+ if (!(flags&0x20) && fd>=0) {
+ ssize_t n=pread(fd,p,size,offset);
+ if (n<0) return -5;
+ }
+ /* Xbox 4 KB allocations can share an Apple page: protection of the
+ physical window is handled by the coarser write tracker. */
+ if (!in_window(guest_pointer(p),size) && mprotect(p,rounded(size),protection & ~PROT_EXEC)) return -12;
+ return guest_pointer(p);
+}
+long host_guest_munmap(uint64_t address,uint64_t size) {
+ if (address+size>0x100000000ull) return -22;
+ if (in_window(address,size)) return 0;
+ host_low_unmap(host_pointer(address),size); return 0;
+}
+long host_guest_mprotect(uint64_t address,uint64_t size,int protection) {
+ if (address+size>0x100000000ull || !size) return -22;
+ uint32_t first=(uint32_t)address & ~(PAGE-1);
+ if(in_window((uint32_t)address,size) && !(protection&PROT_WRITE)) {
+ /* Xbox allocations/protection boundaries are 4 KB. Rounding a read-only
+ buffer outwards also protects a neighboring writable zlib buffer.
+ Enforce read-only only on complete 16 KB pages; boundary pages stay
+ writable. VirtualProtect still records exact Xbox permissions in
+ xbox_memory.c. Texture write tracking independently covers them. */
+ uint64_t end=address+size;
+ if(address!=first && mprotect(host_pointer(first),PAGE,PROT_READ|PROT_WRITE))return -12;
+ if((end&(PAGE-1)) && mprotect(host_pointer(end&~(uint64_t)(PAGE-1)),PAGE,PROT_READ|PROT_WRITE))return -12;
+ uint64_t inner_start=rounded(address),inner_end=end&~(uint64_t)(PAGE-1);
+ return inner_end>inner_start && mprotect(host_pointer(inner_start),inner_end-inner_start,protection&~PROT_EXEC) ? -12:0;
+ }
+ return mprotect(host_pointer(first),rounded(address+size-first),protection & ~PROT_EXEC) ? -12 : 0;
+}
+
+/* A streaming worker can fault while the render thread protects the same
+ 16 KB page. Serialize the flag and VM change together. This lock never
+ encloses guest-memory accesses, so a synchronous fault cannot reenter it. */
+static void page_lock(unsigned page) {
+ while(__atomic_exchange_n(&page_locks[page],1,__ATOMIC_ACQUIRE)) {}
+}
+static void page_unlock(unsigned page) { __atomic_store_n(&page_locks[page],0,__ATOMIC_RELEASE); }
+static void mark_written(unsigned page) {
+ if(mprotect(host_pointer(HALO_GUEST_WINDOW_BASE+page*PAGE),PAGE,PROT_READ|PROT_WRITE))_exit(128+SIGBUS);
+ __atomic_store_n(&generations[page],__atomic_add_fetch(&serial,1,__ATOMIC_RELAXED),__ATOMIC_RELEASE);
+ protected_pages[page]=0;
+}
+static void fault(int sig,siginfo_t *info,void *context) {
+ uintptr_t p=(uintptr_t)info->si_addr;
+ if (host_low_owns(p,1) && in_window((uint32_t)p,1)) {
+ unsigned page=((uint32_t)p-HALO_GUEST_WINDOW_BASE)/PAGE;
+ page_lock(page);
+ /* A second writer may already have repaired this page after our fault
+ was queued. Remember that it was tracked even when now writable. */
+ if (watched_pages[page]) { mark_written(page);page_unlock(page);return; }
+ page_unlock(page);
+ }
+ struct sigaction *old=sig==SIGBUS?&old_bus:&old_segv;
+ sigaction(sig,old,NULL);
+ if ((old->sa_flags&SA_SIGINFO) && old->sa_sigaction) old->sa_sigaction(sig,info,context);
+ else if (old->sa_handler!=SIG_DFL && old->sa_handler!=SIG_IGN) old->sa_handler(sig);
+}
+void host_install_signal_handlers(void) {
+ struct sigaction action={0}; action.sa_sigaction=fault;
+ action.sa_flags=SA_SIGINFO|SA_ONSTACK; sigemptyset(&action.sa_mask);
+ sigaction(SIGBUS,&action,&old_bus);sigaction(SIGSEGV,&action,&old_segv);
+}
+void host_memory_watch_initialize(void) { watch_enabled=1; }
+void host_memory_watch_protect(uint32_t address,uint32_t size) {
+ if (!watch_enabled || !size || !in_window(address,size)) return;
+ unsigned first=(address-HALO_GUEST_WINDOW_BASE)/PAGE;
+ unsigned last=(address+size-1-HALO_GUEST_WINDOW_BASE)/PAGE;
+ for(unsigned i=first;i<=last;i++) {
+ page_lock(i);
+ if(!protected_pages[i]) {
+ watched_pages[i]=1;
+ if(!mprotect(host_pointer(HALO_GUEST_WINDOW_BASE+i*PAGE),PAGE,PROT_READ))protected_pages[i]=1;
+ }
+ page_unlock(i);
+ }
+}
+uint32_t host_memory_watch_serial(void) { return __atomic_load_n(&serial,__ATOMIC_RELAXED); }
+uint32_t host_memory_watch_generation(uint32_t address,uint32_t size) {
+ if (!size || !in_window(address,size)) return 0;
+ uint32_t result=0;
+ for(unsigned i=(address-HALO_GUEST_WINDOW_BASE)/PAGE;i<=(address+size-1-HALO_GUEST_WINDOW_BASE)/PAGE;i++) {
+ uint32_t generation=__atomic_load_n(&generations[i],__ATOMIC_ACQUIRE);
+ if(generation>result)result=generation;
+ }
+ return result;
+}
+void host_memory_watch_prepare_write(uint32_t address,uint32_t size) {
+ if (!size || !in_window(address,size)) return;
+ for(unsigned i=(address-HALO_GUEST_WINDOW_BASE)/PAGE;i<=(address+size-1-HALO_GUEST_WINDOW_BASE)/PAGE;i++) {
+ page_lock(i);if(protected_pages[i])mark_written(i);page_unlock(i);
+ }
+}
+void host_memory_watch_forget(uint32_t address,uint32_t size) {
+ if (!size || !in_window(address,size)) return;
+ for(unsigned i=(address-HALO_GUEST_WINDOW_BASE)/PAGE;i<=(address+size-1-HALO_GUEST_WINDOW_BASE)/PAGE;i++) {
+ page_lock(i);mark_written(i);page_unlock(i);
+ }
+}
diff --git a/port/ios/host/host_metalfx.m b/port/ios/host/host_metalfx.m
new file mode 100644
index 0000000000..be593824fa
--- /dev/null
+++ b/port/ios/host/host_metalfx.m
@@ -0,0 +1,161 @@
+#include
+#if TARGET_OS_SIMULATOR
+#include "host_debug.h"
+int halo_metalfx_supported(void) {return 0;}
+int halo_metalfx_enabled(void) {return 0;}
+void halo_metalfx_set_enabled(int enabled) {(void)enabled;}
+unsigned int host_ios_render_height(void) {return 0;}
+int host_ios_metalfx_present(unsigned int t,unsigned int w,unsigned int h,unsigned int ow,unsigned int oh) {(void)t;(void)w;(void)h;(void)ow;(void)oh;return 0;}
+#else
+/* Optional ANGLE Metal -> IOSurface -> MetalFX presentation. Both APIs share the
+ input image; explicit completion fences protect ownership across APIs. */
+#if HALO_MACOS
+#import
+#else
+#import
+#endif
+#include
+#include
+
+#import
+#import
+#import
+#import
+#include "ios_host.h"
+#include "host_gl_dispatch.h"
+#include "host_debug.h"
+
+static BOOL enabled;
+static id device;
+static id queue;
+static id pending;
+static id scaler;
+
+static CVMetalTextureCacheRef metalCache;
+static CVPixelBufferRef pixels;
+static GLuint sharedTexture;
+static CVMetalTextureRef metalImage;
+static id output;
+#if HALO_MACOS
+@interface HaloMetalSurface : NSView
+@end
+@implementation HaloMetalSurface
+-(NSView *)hitTest:(NSPoint)point {(void)point;return nil;}
+@end
+static NSView *surface;
+#else
+static UIView *surface;
+#endif
+static CAMetalLayer *layer;
+static GLuint readFBO,writeFBO;
+static unsigned inputW,inputH,outputW,outputH;
+
+int halo_metalfx_supported(void) {
+ if(!device)device=MTLCreateSystemDefaultDevice();
+ return device && [MTLFXSpatialScalerDescriptor supportsDevice:device];
+}
+int halo_metalfx_enabled(void) {return enabled;}
+void halo_metalfx_set_enabled(int value) {
+ enabled=value && halo_graphics_metal() && halo_metalfx_supported();
+ if(!enabled)surface.hidden=YES;
+ host_logf(HOST_LOG_INFO,"MetalFX spatial upscaling %s",enabled?"enabled":"disabled");
+}
+unsigned int host_ios_render_height(void) {return enabled?720:0;}
+static void releaseImages(void) {
+ [pending waitUntilCompleted];pending=nil;
+ halo_graphics_shared_destroy();sharedTexture=0;
+ if(metalImage){CFRelease(metalImage);metalImage=NULL;}
+ if(pixels){CFRelease(pixels);pixels=NULL;}
+ if(metalCache)CVMetalTextureCacheFlush(metalCache,0);
+ scaler=nil;output=nil;inputW=inputH=outputW=outputH=0;
+}
+static BOOL configure(unsigned w,unsigned h,unsigned ow,unsigned oh) {
+ releaseImages();
+ if(!queue)queue=[device newCommandQueue];
+ if(!queue)return NO;
+ if(!metalCache && CVMetalTextureCacheCreate(NULL,NULL,device,NULL,&metalCache)!=kCVReturnSuccess)return NO;
+ NSDictionary *attributes=@{(id)kCVPixelBufferIOSurfacePropertiesKey:@{},(id)kCVPixelBufferMetalCompatibilityKey:@YES};
+ if(CVPixelBufferCreate(NULL,w,h,kCVPixelFormatType_32BGRA,(__bridge CFDictionaryRef)attributes,&pixels)!=kCVReturnSuccess)return NO;
+ sharedTexture=halo_graphics_shared_create(CVPixelBufferGetIOSurface(pixels),w,h);
+ if(!sharedTexture)return NO;
+ MTLFXSpatialScalerDescriptor *desc=[MTLFXSpatialScalerDescriptor new];
+ desc.inputWidth=w;desc.inputHeight=h;desc.outputWidth=ow;desc.outputHeight=oh;
+ desc.colorTextureFormat=desc.outputTextureFormat=MTLPixelFormatBGRA8Unorm;
+ desc.colorProcessingMode=MTLFXSpatialScalerColorProcessingModePerceptual;
+ scaler=[desc newSpatialScalerWithDevice:device];if(!scaler)return NO;
+ NSDictionary *textureAttributes=@{(id)kCVMetalTextureUsage:@(scaler.colorTextureUsage)};
+ if(CVMetalTextureCacheCreateTextureFromImage(NULL,metalCache,pixels,(__bridge CFDictionaryRef)textureAttributes,MTLPixelFormatBGRA8Unorm,w,h,0,&metalImage)!=kCVReturnSuccess)return NO;
+ MTLTextureDescriptor *texture=[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm width:ow height:oh mipmapped:NO];
+ texture.storageMode=MTLStorageModePrivate;texture.usage=scaler.outputTextureUsage;
+ output=[device newTextureWithDescriptor:texture];if(!output)return NO;
+ scaler.colorTexture=CVMetalTextureGetTexture(metalImage);scaler.outputTexture=output;
+ scaler.inputContentWidth=w;scaler.inputContentHeight=h;
+ if(!readFBO)glGenFramebuffers(1,&readFBO);
+ if(!writeFBO)glGenFramebuffers(1,&writeFBO);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER,writeFBO);
+ glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,sharedTexture,0);
+ if(glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER)!=GL_FRAMEBUFFER_COMPLETE)return NO;
+ inputW=w;inputH=h;outputW=ow;outputH=oh;
+ host_logf(HOST_LOG_INFO,"MetalFX configured %ux%u -> %ux%u",w,h,ow,oh);
+ return YES;
+}
+int host_ios_metalfx_present(unsigned int texture,unsigned int w,unsigned int h,unsigned int ow,unsigned int oh) {
+ if(!enabled || !w || !h || !ow || !oh)return 0;
+ @autoreleasepool {
+ GLint oldRead,oldWrite,oldTexture;glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING,&oldRead);glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING,&oldWrite);glGetIntegerv(GL_TEXTURE_BINDING_2D,&oldTexture);
+ BOOL scissor=glIsEnabled(GL_SCISSOR_TEST),success=YES;
+ [pending waitUntilCompleted];
+ if(pending.status==MTLCommandBufferStatusError){host_logf(HOST_LOG_ERROR,"MetalFX: %s",pending.error.localizedDescription.UTF8String);success=NO;}
+ pending=nil;
+ if(success && (w!=inputW || h!=inputH || ow!=outputW || oh!=outputH))success=configure(w,h,ow,oh);
+ if(success && !surface) {
+#if HALO_MACOS
+ extern void *host_macos_game_window(void);
+ NSWindow *window=(__bridge NSWindow *)host_macos_game_window();
+ NSView *root=window.contentView;
+ if(root){surface=[[HaloMetalSurface alloc]initWithFrame:root.bounds];surface.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable;surface.wantsLayer=YES;layer=[CAMetalLayer layer];layer.device=device;layer.pixelFormat=MTLPixelFormatBGRA8Unorm;layer.framebufferOnly=NO;[surface.layer addSublayer:layer];[root addSubview:surface];}
+ else success=NO;
+#else
+ UIWindow *window=nil;
+ for(UIScene *scene in UIApplication.sharedApplication.connectedScenes)if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *candidate in ((UIWindowScene*)scene).windows)if(candidate.isKeyWindow)window=candidate;
+ UIView *root=window.rootViewController.view;
+ if(root){surface=[[UIView alloc]initWithFrame:root.bounds];surface.userInteractionEnabled=NO;surface.autoresizingMask=UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;layer=[CAMetalLayer layer];layer.device=device;layer.pixelFormat=MTLPixelFormatBGRA8Unorm;layer.framebufferOnly=NO;[surface.layer addSublayer:layer];[root insertSubview:surface atIndex:0];}
+ else success=NO;
+#endif
+ }
+ if(success)success=halo_graphics_shared_begin();
+ if(success) {
+ glBindFramebuffer(GL_READ_FRAMEBUFFER,readFBO);glFramebufferTexture2D(GL_READ_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0);
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER,writeFBO);
+ glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,sharedTexture,0);glDisable(GL_SCISSOR_TEST);
+ /* Guest row zero already denotes the top, as Metal expects. */
+ glBlitFramebuffer(0,0,w,h,0,0,w,h,GL_COLOR_BUFFER_BIT,GL_NEAREST);
+ success=halo_graphics_shared_end();
+ layer.frame=surface.bounds;
+#if HALO_MACOS
+ layer.contentsScale=surface.window.backingScaleFactor;
+#else
+ layer.contentsScale=surface.window.screen.scale;
+#endif
+ CGSize size=CGSizeMake(round(surface.bounds.size.width*layer.contentsScale),round(surface.bounds.size.height*layer.contentsScale));
+ if(!CGSizeEqualToSize(layer.drawableSize,size))layer.drawableSize=size;
+ id drawable=[layer nextDrawable];
+ if(success && drawable && ow<=drawable.texture.width && oh<=drawable.texture.height) {
+ id commands=[queue commandBuffer];
+ [scaler encodeToCommandBuffer:commands];
+ MTLRenderPassDescriptor *pass=[MTLRenderPassDescriptor renderPassDescriptor];
+ pass.colorAttachments[0].texture=drawable.texture;pass.colorAttachments[0].loadAction=MTLLoadActionClear;pass.colorAttachments[0].storeAction=MTLStoreActionStore;pass.colorAttachments[0].clearColor=MTLClearColorMake(0,0,0,1);
+ [[commands renderCommandEncoderWithDescriptor:pass]endEncoding];
+ id blit=[commands blitCommandEncoder];
+ [blit copyFromTexture:output sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(0,0,0) sourceSize:MTLSizeMake(ow,oh,1) toTexture:drawable.texture destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake((drawable.texture.width-ow)/2,(drawable.texture.height-oh)/2,0)];
+ [blit endEncoding];[commands presentDrawable:drawable];[commands commit];pending=commands;surface.hidden=NO;
+ } else {host_logf(HOST_LOG_WARN,"MetalFX drawable unavailable or too small (%ux%u requested)",ow,oh);surface.hidden=YES;success=NO;}
+ }
+ glBindFramebuffer(GL_READ_FRAMEBUFFER,oldRead);glBindFramebuffer(GL_DRAW_FRAMEBUFFER,oldWrite);glBindTexture(GL_TEXTURE_2D,oldTexture);
+ if(scissor)glEnable(GL_SCISSOR_TEST);else glDisable(GL_SCISSOR_TEST);
+ if(!success){halo_metalfx_set_enabled(0);releaseImages();}
+ return success;
+ }
+}
+
+#endif
diff --git a/port/ios/host/host_orientation.h b/port/ios/host/host_orientation.h
new file mode 100644
index 0000000000..b4e5e59592
--- /dev/null
+++ b/port/ios/host/host_orientation.h
@@ -0,0 +1,11 @@
+#pragma once
+#import
+
+/* Keep launch, import, Files, and SDL on the same landscape policy. */
+@interface HaloLandscapeController : UIViewController
+@end
+
+@interface HaloLandscapeDocumentPicker : UIDocumentPickerViewController
+@end
+
+void host_ios_require_landscape(UIWindow *window);
diff --git a/port/ios/host/host_orientation.m b/port/ios/host/host_orientation.m
new file mode 100644
index 0000000000..2ba5429739
--- /dev/null
+++ b/port/ios/host/host_orientation.m
@@ -0,0 +1,82 @@
+#import "host_orientation.h"
+#include "ios_host.h"
+
+/* SDL 3.4.16 owns these controllers. These categories add only orientation
+ preferences inherited from UIKit, never replace SDL's own methods. Recheck
+ the selectors when upgrading SDL; no runtime class mutation is needed. */
+@interface SDL_uikitviewcontroller : UIViewController
+@end
+@interface SDLLaunchScreenController : UIViewController
+@end
+@interface SDLUIKitSceneDelegate : NSObject
+@end
+
+static BOOL landscape_lock(UIViewController *controller) {
+ /* Don't lock a transient portrait scene before the geometry request has
+ completed. The scene callback updates this preference after rotation. */
+ return UIInterfaceOrientationIsLandscape(controller.viewIfLoaded.window.windowScene.interfaceOrientation);
+}
+
+#define HALO_LANDSCAPE_PREFERENCES \
+- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation { return UIInterfaceOrientationLandscapeRight; } \
+- (BOOL)prefersInterfaceOrientationLocked { return landscape_lock(self); }
+
+@implementation HaloLandscapeController
+- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; }
+HALO_LANDSCAPE_PREFERENCES
+- (void)viewDidAppear:(BOOL)animated {
+ [super viewDidAppear:animated];
+ host_ios_require_landscape(self.view.window);
+}
+@end
+
+@implementation HaloLandscapeDocumentPicker
+- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; }
+HALO_LANDSCAPE_PREFERENCES
+@end
+
+@implementation SDL_uikitviewcontroller (HaloLandscape)
+HALO_LANDSCAPE_PREFERENCES
+@end
+
+@implementation SDLLaunchScreenController (HaloLandscape)
+HALO_LANDSCAPE_PREFERENCES
+@end
+
+@implementation SDLUIKitSceneDelegate (HaloLandscape)
+/* iPadOS 27 consults the scene delegate in preference to Info.plist. */
+- (UIInterfaceOrientationMask)supportedInterfaceOrientationsForWindowScene:(UIWindowScene *)scene {
+ (void)scene;
+ return UIInterfaceOrientationMaskLandscape;
+}
+#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260000
+- (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previousGeometry API_AVAILABLE(ios(26.0)) {
+ BOOL wasLandscape=UIInterfaceOrientationIsLandscape(previousGeometry.interfaceOrientation);
+ BOOL isLandscape=UIInterfaceOrientationIsLandscape(scene.effectiveGeometry.interfaceOrientation);
+ if (wasLandscape != isLandscape) {
+ for (UIWindow *window in scene.windows) {
+ UIViewController *controller=window.rootViewController;
+ while (controller.presentedViewController) controller=controller.presentedViewController;
+ [controller setNeedsUpdateOfPrefersInterfaceOrientationLocked];
+ }
+ }
+ CGSize size=scene.effectiveGeometry.coordinateSpace.bounds.size;
+ host_logf(HOST_LOG_INFO,"scene geometry %.0fx%.0f orientation=%ld locked=%d",
+ size.width,size.height,(long)scene.effectiveGeometry.interfaceOrientation,
+ scene.effectiveGeometry.isInterfaceOrientationLocked);
+}
+#endif
+@end
+
+void host_ios_require_landscape(UIWindow *window) {
+ if (!window) return;
+ [window.rootViewController setNeedsUpdateOfSupportedInterfaceOrientations];
+#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260000
+ if (@available(iOS 26.0,*)) [window.rootViewController setNeedsUpdateOfPrefersInterfaceOrientationLocked];
+#endif
+ UIWindowSceneGeometryPreferencesIOS *geometry=[[UIWindowSceneGeometryPreferencesIOS alloc]
+ initWithInterfaceOrientations:UIInterfaceOrientationMaskLandscape];
+ [window.windowScene requestGeometryUpdateWithPreferences:geometry errorHandler:^(NSError *error) {
+ host_logf(HOST_LOG_WARN,"landscape request: %s",error.localizedDescription.UTF8String);
+ }];
+}
diff --git a/port/ios/host/host_sdl.c b/port/ios/host/host_sdl.c
new file mode 100644
index 0000000000..ab5d6adb0c
--- /dev/null
+++ b/port/ios/host/host_sdl.c
@@ -0,0 +1,522 @@
+#include "host_graphics.h"
+/*
+HOST_SDL.C
+
+SDL3 on behalf of the guest (guest/runtime/guest_sdl.c). SDL objects are
+64-bit pointers, which the guest cannot hold; it gets small handles into the
+table here instead.
+
+The guest calls these on its own threads, whose stacks remain inside the
+native guest arena (host_thread.c). SDL's audio thread is the exception: it has no guest stack,
+so the audio callback is handed to a thread that has one.
+*/
+
+#include "host.h"
+
+#include
+#include
+#include
+#include
+#include
+#include "ios_host.h"
+
+#define HANDLE_COUNT 256
+
+enum handle_type
+{
+ _handle_free,
+ _handle_window,
+ _handle_context,
+ _handle_gamepad,
+ _handle_audio,
+};
+
+struct handle
+{
+ int type;
+ void *object;
+};
+
+static struct handle handles[HANDLE_COUNT];
+static pthread_mutex_t handle_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static uint32_t handle_new(int type, void *object)
+{
+ uint32_t index;
+
+ if (!object)
+ return 0;
+ pthread_mutex_lock(&handle_lock);
+ /* an object that already has a handle keeps it */
+ for (index = 1; index < HANDLE_COUNT; index++)
+ {
+ if (handles[index].type == type && handles[index].object == object)
+ {
+ pthread_mutex_unlock(&handle_lock);
+ return index;
+ }
+ }
+ for (index = 1; index < HANDLE_COUNT; index++)
+ {
+ if (handles[index].type == _handle_free)
+ {
+ handles[index].type = type;
+ handles[index].object = object;
+ pthread_mutex_unlock(&handle_lock);
+ return index;
+ }
+ }
+ pthread_mutex_unlock(&handle_lock);
+ host_logf(HOST_LOG_ERROR, "out of SDL handles");
+ return 0;
+}
+
+static void *handle_get(uint32_t handle, int type)
+{
+ void *object = NULL;
+
+ if (handle == 0 || handle >= HANDLE_COUNT)
+ return NULL;
+ pthread_mutex_lock(&handle_lock);
+ if (handles[handle].type == type)
+ object = handles[handle].object;
+ pthread_mutex_unlock(&handle_lock);
+ return object;
+}
+
+/* ---------- general */
+
+int host_sdl_init(uint32_t flags)
+{
+ return SDL_Init((SDL_InitFlags)flags);
+}
+
+int host_sdl_set_hint(const char *name, const char *value)
+{
+ return SDL_SetHint(name, value);
+}
+
+void host_sdl_get_error(char *buffer, uint32_t size)
+{
+ SDL_strlcpy(buffer, SDL_GetError(), size);
+}
+
+void host_sdl_scancode_name(int32_t scancode, char *buffer, uint32_t size)
+{
+ SDL_strlcpy(buffer, SDL_GetScancodeName((SDL_Scancode)scancode), size);
+}
+
+int32_t host_sdl_scancode_from_name(const char *name)
+{
+ return (int32_t)SDL_GetScancodeFromName(name);
+}
+
+int64_t host_sdl_ticks(void)
+{
+ return (int64_t)SDL_GetTicks();
+}
+
+int64_t host_sdl_thread_id(void)
+{
+ return (int64_t)SDL_GetCurrentThreadID();
+}
+
+/* ---------- video */
+
+uint32_t host_sdl_create_window(const char *title, int width, int height, int64_t flags)
+{
+ #if HALO_MACOS
+ flags=(flags & ~SDL_WINDOW_FULLSCREEN) | SDL_WINDOW_RESIZABLE;
+ width=1280;height=720;
+#endif
+ if(halo_graphics_metal())flags=(flags & ~SDL_WINDOW_OPENGL) | SDL_WINDOW_METAL;
+ return handle_new(_handle_window, SDL_CreateWindow(title, width, height, (SDL_WindowFlags)flags));
+}
+
+void host_sdl_window_size_in_pixels(uint32_t window, int *width, int *height)
+{
+ SDL_Window *object = handle_get(window, _handle_window);
+
+ *width = 0;
+ *height = 0;
+ if (object)
+ SDL_GetWindowSizeInPixels(object, width, height);
+}
+
+int host_sdl_set_relative_mouse(uint32_t window, int enabled)
+{
+ SDL_Window *object = handle_get(window, _handle_window);
+
+ return object ? SDL_SetWindowRelativeMouseMode(object, enabled != 0) : 0;
+}
+
+int host_sdl_gl_set_attribute(int attribute, int value)
+{
+ if(halo_graphics_metal())return 1;
+ /* EAGL exposes ES 3.0; capabilities are queried from the created context. */
+ if (attribute == SDL_GL_CONTEXT_MINOR_VERSION) value = 0;
+ return SDL_GL_SetAttribute((SDL_GLAttr)attribute, value);
+}
+
+uint32_t host_sdl_gl_create_context(uint32_t window)
+{
+ SDL_Window *object = handle_get(window, _handle_window);
+ SDL_GLContext context = object ? (halo_graphics_metal()?halo_graphics_create(object):SDL_GL_CreateContext(object)) : NULL;
+ if (context) host_ios_touch_attach(object);
+ return handle_new(_handle_context, context);
+}
+
+int host_sdl_gl_make_current(uint32_t window, uint32_t context)
+{
+ if(halo_graphics_metal())return halo_graphics_make_current();
+ return SDL_GL_MakeCurrent(handle_get(window, _handle_window), handle_get(context, _handle_context));
+}
+
+int host_sdl_gl_set_swap_interval(int interval)
+{
+ return halo_graphics_metal()?halo_graphics_swap_interval(interval):SDL_GL_SetSwapInterval(interval);
+}
+
+#if HALO_MACOS
+/* Capture this application's own framebuffer for unattended renderer checks. */
+static void test_capture(SDL_Window *window) {
+ static int frames;const char *path=SDL_getenv("HALO_MAC_TEST_FRAME");
+ const char *captureAt=SDL_getenv("HALO_MAC_TEST_FRAME_AT");
+ int target=captureAt?atoi(captureAt):60;
+ if(!path || ++frames!=(target>0?target:60))return;
+ int w,h;SDL_GetWindowSizeInPixels(window,&w,&h);
+ unsigned char *pixels=SDL_malloc((size_t)w*h*4);if(!pixels)return;
+ void (*readPixels)(int,int,int,int,unsigned,unsigned,void*)=halo_graphics_proc("glReadPixels");
+ void (*getInteger)(unsigned,int*)=halo_graphics_proc("glGetIntegerv");
+ void (*bindFramebuffer)(unsigned,unsigned)=halo_graphics_proc("glBindFramebuffer");
+ int previous=0;getInteger(0x8CAA,&previous);bindFramebuffer(0x8CA8,0);
+ readPixels(0,0,w,h,0x1908,0x1401,pixels);bindFramebuffer(0x8CA8,previous);
+ SDL_Surface *surface=SDL_CreateSurfaceFrom(w,h,SDL_PIXELFORMAT_RGBA32,pixels,w*4);
+ if(surface){SDL_FlipSurface(surface,SDL_FLIP_VERTICAL);SDL_SaveBMP(surface,path);SDL_DestroySurface(surface);}
+ SDL_free(pixels);host_logf(HOST_LOG_INFO,"Saved test framebuffer: %s",path);
+}
+#endif
+int host_sdl_gl_swap_window(uint32_t window)
+{
+ SDL_Window *object = handle_get(window, _handle_window);
+
+#if HALO_MACOS
+ if(object)test_capture(object);
+#endif
+ return object ? (halo_graphics_metal()?halo_graphics_swap():SDL_GL_SwapWindow(object)) : 0;
+}
+
+/* ---------- events */
+
+/* Launch-only menu input for integration checks. Requires an explicit menu
+ and deadline as well, and never persists into the user's next launch. */
+static void test_menu_input(void) {
+ static const char *next;
+ static int initialized, held;
+ static Uint64 release_at;
+ if(!initialized) {
+ initialized=1;
+ if(SDL_getenv("HALO_MENU_OPEN") && SDL_getenv("HALO_EXIT_AFTER"))
+ next=SDL_getenv("HALO_APPLE_TEST_KEYS");
+ }
+ Uint64 now=SDL_GetTicks();
+ if(held && now>=release_at) {
+ SDL_Event e={0};e.type=SDL_EVENT_KEY_UP;e.key.scancode=(SDL_Scancode)held;
+ SDL_PushEvent(&e);held=0;
+ }
+ if(held || !next || !*next)return;
+ char *end;unsigned long at=strtoul(next,&end,10);
+ if(end==next || *end!=':'){next=NULL;return;}
+ if(now=sizeof(key)){next=NULL;return;}
+ memcpy(key,name,length);key[length]=0;
+ next=name+length;if(*next==',')next++;
+ SDL_Scancode scancode=SDL_GetScancodeFromName(key);
+ if(scancode==SDL_SCANCODE_UNKNOWN)return;
+ SDL_Event e={0};e.type=SDL_EVENT_KEY_DOWN;e.key.scancode=scancode;e.key.down=true;
+ SDL_PushEvent(&e);held=scancode;release_at=now+120;
+ host_logf(HOST_LOG_INFO,"Menu test key: %s",key);
+}
+
+int host_sdl_poll_event(void *event)
+{
+ SDL_Event host_event;
+ test_menu_input();
+
+ #ifdef HALO_IOS_BROWSER_HOST
+ { extern void host_ios_room_tick(void); host_ios_room_tick(); }
+#endif
+ if (!SDL_PollEvent(&host_event))
+ return 0;
+ /* the layouts agree except for the pointers of text, drop and user
+ events, which the guest does not read */
+ memcpy(event, &host_event, sizeof(host_event));
+ return 1;
+}
+
+int host_apple_poll_event(void *event, unsigned int audio_service)
+{
+#if HALO_MACOS
+ extern void host_macos_audio_event_begin(uint32_t callback);
+ extern void host_macos_audio_event_end(void);
+ host_macos_audio_event_begin(audio_service);
+#else
+ (void)audio_service;
+#endif
+ int result = host_sdl_poll_event(event);
+#if HALO_MACOS
+ host_macos_audio_event_end();
+#endif
+ return result;
+}
+
+/* ---------- gamepads */
+
+int host_sdl_get_gamepads(uint32_t *ids, int capacity)
+{
+ return host_ios_gamepads(ids, capacity);
+}
+
+uint32_t host_sdl_open_gamepad(uint32_t id)
+{
+ SDL_Gamepad *gamepad = SDL_OpenGamepad((SDL_JoystickID)id);
+
+ if (gamepad)
+ host_logf(HOST_LOG_INFO, "gamepad %u: %s (type %d, %04x:%04x)", (unsigned)id, SDL_GetGamepadName(gamepad),
+ (int)SDL_GetGamepadType(gamepad), SDL_GetGamepadVendor(gamepad), SDL_GetGamepadProduct(gamepad));
+ return handle_new(_handle_gamepad, gamepad);
+}
+
+uint32_t host_sdl_gamepad_from_id(uint32_t id)
+{
+ return handle_new(_handle_gamepad, SDL_GetGamepadFromID((SDL_JoystickID)id));
+}
+
+int host_sdl_gamepad_axis(uint32_t gamepad, int axis)
+{
+ SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad);
+
+ return object ? host_ios_gamepad_axis(object, axis) : 0;
+}
+
+int host_sdl_gamepad_button(uint32_t gamepad, int button)
+{
+ SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad);
+
+ return object ? host_ios_gamepad_button(object, button) : 0;
+}
+
+int host_sdl_gamepad_type(uint32_t gamepad)
+{
+ SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad);
+
+ return object ? host_ios_gamepad_type(object) : SDL_GAMEPAD_TYPE_UNKNOWN;
+}
+
+int host_sdl_rumble_gamepad(uint32_t gamepad, uint32_t low, uint32_t high, uint32_t milliseconds)
+{
+ SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad);
+
+ return object ? SDL_RumbleGamepad(object, (Uint16)low, (Uint16)high, milliseconds) : 0;
+}
+
+/* ---------- audio */
+
+/* SDL holds its stream lock while calling audio_callback. The guest worker
+must stage its PCM here: calling SDL_PutAudioStreamData from that worker would
+wait for the lock held by the callback that is waiting for the worker. Only
+the original SDL callback thread submits the staged samples to the stream. */
+struct audio_binding
+{
+ uint32_t handle;
+ uint32_t callback;
+ uint32_t userdata;
+ pthread_mutex_t lock;
+ pthread_cond_t requested;
+ pthread_cond_t done;
+ int pending;
+ int additional;
+ int total;
+ unsigned char *samples;
+ int length;
+ int capacity;
+ SDL_AudioFormat format;
+ int logged_output;
+};
+
+static __thread struct audio_binding *audio_worker_binding;
+
+static void *audio_thread(void *context)
+{
+ struct audio_binding *binding = context;
+
+ audio_worker_binding = binding;
+ pthread_mutex_lock(&binding->lock);
+ for (;;)
+ {
+ int additional, total;
+
+ while (!binding->pending)
+ pthread_cond_wait(&binding->requested, &binding->lock);
+ additional = binding->additional;
+ total = binding->total;
+ pthread_mutex_unlock(&binding->lock);
+ host_call_guest(binding->callback, binding->userdata, binding->handle, (uint32_t)additional, (uint32_t)total);
+ pthread_mutex_lock(&binding->lock);
+ binding->pending = 0;
+ pthread_cond_signal(&binding->done);
+ }
+ return NULL;
+}
+
+static void SDLCALL audio_callback(void *userdata, SDL_AudioStream *stream, int additional, int total)
+{
+ struct audio_binding *binding = userdata;
+
+ pthread_mutex_lock(&binding->lock);
+ binding->length = 0;
+ binding->additional = additional;
+ binding->total = total;
+ binding->pending = 1;
+ pthread_cond_signal(&binding->requested);
+ while (binding->pending)
+ pthread_cond_wait(&binding->done, &binding->lock);
+ if (binding->length && !SDL_PutAudioStreamData(stream, binding->samples, binding->length))
+ host_logf(HOST_LOG_ERROR, "cannot submit audio: %s", SDL_GetError());
+ #if HALO_MACOS
+ /* Opt-in liveness trace for the real AppKit menu-tracking regression. */
+ static Uint64 last_trace;
+ int trace = SDL_getenv("HALO_MAC_TEST_MENU_AUDIO") && SDL_GetTicks()-last_trace >= 1000;
+ if(trace)last_trace=SDL_GetTicks();
+#else
+ int trace = 0;
+#endif
+ if ((!binding->logged_output || trace) && binding->format == SDL_AUDIO_F32)
+ {
+ const float *samples = (const float *)binding->samples;
+ float peak = 0.0f;
+ for (int i = 0; i < binding->length / (int)sizeof(float); ++i)
+ peak = SDL_max(peak, SDL_fabsf(samples[i]));
+ if (peak > 0.0001f || trace)
+ {
+ host_logf(HOST_LOG_INFO, "audio output active: %d PCM bytes, peak %.4f", binding->length, peak);
+ binding->logged_output = 1;
+ }
+ }
+ pthread_mutex_unlock(&binding->lock);
+}
+
+uint32_t host_sdl_open_audio_stream(uint32_t device, const void *spec, uint32_t callback, uint32_t userdata)
+{
+ struct audio_binding *binding = SDL_calloc(1, sizeof(*binding));
+ SDL_AudioStream *stream;
+
+ if (!binding)
+ return 0;
+ binding->callback = callback;
+ binding->userdata = userdata;
+ binding->format = ((const SDL_AudioSpec *)spec)->format;
+ pthread_mutex_init(&binding->lock, NULL);
+ pthread_cond_init(&binding->requested, NULL);
+ pthread_cond_init(&binding->done, NULL);
+ stream = SDL_OpenAudioDeviceStream((SDL_AudioDeviceID)device, spec,
+ callback ? audio_callback : NULL, binding);
+ if (!stream)
+ {
+ pthread_cond_destroy(&binding->done);
+ pthread_cond_destroy(&binding->requested);
+ pthread_mutex_destroy(&binding->lock);
+ SDL_free(binding);
+ return 0;
+ }
+ /* the device starts paused, so no callback can run before this */
+ binding->handle = handle_new(_handle_audio, stream);
+ if (callback && host_native_thread_create(audio_thread, binding, 256 * 1024) != 0)
+ host_fatal("cannot start the audio thread");
+ return binding->handle;
+}
+
+int host_sdl_put_audio_stream_data(uint32_t stream, const void *data, int length)
+{
+ struct audio_binding *binding = audio_worker_binding;
+
+ if (binding && binding->handle == stream)
+ {
+ /* The callback is asleep until this worker signals completion. Copy
+ now because the guest's mix buffer is on its stack and is reused. */
+ if (length < 0 || (!data && length) || length > INT_MAX - binding->length)
+ return SDL_SetError("invalid guest audio buffer");
+ int needed = binding->length + length;
+ if (needed > binding->capacity)
+ {
+ int capacity = SDL_max(needed, 65536);
+ void *samples = SDL_realloc(binding->samples, (size_t)capacity);
+ if (!samples)
+ return SDL_OutOfMemory();
+ binding->samples = samples;
+ binding->capacity = capacity;
+ }
+ if (length)
+ SDL_memcpy(binding->samples + binding->length, data, (size_t)length);
+ binding->length = needed;
+ return 1;
+ }
+ SDL_AudioStream *object = handle_get(stream, _handle_audio);
+
+ return object ? SDL_PutAudioStreamData(object, data, length) : 0;
+}
+
+int host_sdl_resume_audio_stream_device(uint32_t stream)
+{
+ SDL_AudioStream *object = handle_get(stream, _handle_audio);
+
+ return object ? SDL_ResumeAudioStreamDevice(object) : 0;
+}
+
+/* ---------- the clipboard (internet play's invite links) */
+
+int host_sdl_set_clipboard_text(const char *text)
+{
+ return SDL_SetClipboardText(text) ? 1 : 0;
+}
+
+void host_sdl_get_clipboard_text(char *buffer, uint32_t size)
+{
+ char *text = SDL_GetClipboardText();
+
+ SDL_strlcpy(buffer, text ? text : "", size);
+ SDL_free(text);
+}
+
+/* UIKit renders to a view framebuffer, rather than framebuffer zero. */
+uint32_t host_ios_default_framebuffer(void)
+{
+ if(halo_graphics_metal())return 0;
+#if !HALO_MACOS
+ for (unsigned i = 1; i < HANDLE_COUNT; i++)
+ if (handles[i].type == _handle_window)
+ return (uint32_t)SDL_GetNumberProperty(SDL_GetWindowProperties(handles[i].object),
+ SDL_PROP_WINDOW_UIKIT_OPENGL_FRAMEBUFFER_NUMBER, 0);
+#endif
+ return 0;
+}
+
+int host_sdl_show_simple_message_box(unsigned int flags, const char *title, const char *message)
+{
+ return SDL_ShowSimpleMessageBox((SDL_MessageBoxFlags)flags, title, message, NULL) ? 1 : 0;
+}
+
+void host_sdl_window_size(unsigned int window,int *width,int *height) {
+ SDL_Window *object=handle_get(window,_handle_window);*width=*height=0;
+ if(object)SDL_GetWindowSize(object,width,height);
+}
+void host_sdl_warp_mouse(unsigned int window,float x,float y) {
+ SDL_Window *object=handle_get(window,_handle_window);if(object)SDL_WarpMouseInWindow(object,x,y);
+}
+
+void host_apple_request_quit(void) {
+#if HALO_MACOS
+ SDL_Event event = {0}; event.type = SDL_EVENT_QUIT; SDL_PushEvent(&event);
+#endif
+}
diff --git a/port/ios/host/host_shader_cache.h b/port/ios/host/host_shader_cache.h
new file mode 100644
index 0000000000..6fd67ff8e2
--- /dev/null
+++ b/port/ios/host/host_shader_cache.h
@@ -0,0 +1,4 @@
+#pragma once
+#include
+#include
+void halo_shader_cache_install(EGLDisplay display, PFNEGLGETPROCADDRESSPROC get_proc);
diff --git a/port/ios/host/host_shader_cache.m b/port/ios/host/host_shader_cache.m
new file mode 100644
index 0000000000..0a381a8884
--- /dev/null
+++ b/port/ios/host/host_shader_cache.m
@@ -0,0 +1,107 @@
+/* ANGLE owns the opaque keys and validates compiled blobs. The application
+ provides a bounded private disk cache, scoped to the GPU, OS and ANGLE build.
+ Writes run off the rendering thread; eviction/corruption is just a miss. */
+#import
+#import
+#import
+#include
+#include "host_shader_cache.h"
+#include "ios_host.h"
+
+static NSString *cacheDirectory;
+static NSCache *memoryCache;
+static dispatch_queue_t writer;
+static NSLock *pendingLock;
+static NSUInteger pendingBytes;
+static const NSUInteger maximumBlob=16*1024*1024, maximumDisk=64*1024*1024;
+static BOOL loggedHit,loggedStore;
+
+static NSString *digest(NSData *data) {
+ unsigned char bytes[CC_SHA256_DIGEST_LENGTH];CC_SHA256(data.bytes,(CC_LONG)data.length,bytes);
+ NSMutableString *result=[NSMutableString new];
+ for(unsigned i=0;i65536)return nil;
+ return digest([NSData dataWithBytes:key length:(NSUInteger)size]);
+}
+static void trimDisk(void) {
+ NSFileManager *files=NSFileManager.defaultManager;
+ NSArray *urls=[files contentsOfDirectoryAtURL:[NSURL fileURLWithPath:cacheDirectory]
+ includingPropertiesForKeys:@[NSURLFileSizeKey,NSURLContentModificationDateKey] options:0 error:nil];
+ NSMutableArray *entries=[NSMutableArray new];NSUInteger total=0;
+ for(NSURL *url in urls) {
+ if(![url.pathExtension isEqualToString:@"blob"])continue;
+ NSDictionary *values=[url resourceValuesForKeys:@[NSURLFileSizeKey,NSURLContentModificationDateKey] error:nil];
+ NSUInteger size=[values[NSURLFileSizeKey] unsignedIntegerValue];total+=size;
+ [entries addObject:@{@"url":url,@"size":@(size),@"date":values[NSURLContentModificationDateKey]?:NSDate.distantPast}];
+ }
+ [entries sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"date" ascending:YES]]];
+ for(NSDictionary *entry in entries) {
+ if(total<=maximumDisk)break;
+ [files removeItemAtURL:entry[@"url"] error:nil];total-=[entry[@"size"] unsignedIntegerValue];
+ }
+}
+static void storeBlob(const void *key,EGLsizeiANDROID keySize,const void *value,EGLsizeiANDROID valueSize) {
+ @autoreleasepool {
+ NSString *name=keyName(key,keySize);
+ if(!name || !value || valueSize<=0 || valueSize>maximumBlob)return;
+ NSData *bytes=[NSData dataWithBytes:value length:(NSUInteger)valueSize];
+ [memoryCache setObject:bytes forKey:name cost:bytes.length];
+ [pendingLock lock];
+ BOOL queued=pendingBytes+bytes.length<=maximumDisk;
+ if(queued)pendingBytes+=bytes.length;
+ [pendingLock unlock];
+ if(!queued)return;
+ dispatch_async(writer,^{@autoreleasepool {
+ NSString *path=[cacheDirectory stringByAppendingPathComponent:[name stringByAppendingPathExtension:@"blob"]];
+ BOOL ok=[bytes writeToFile:path options:NSDataWritingAtomic error:nil];
+ if(ok && !loggedStore){loggedStore=YES;host_logf(HOST_LOG_INFO,"ANGLE shader cache: stored compiled blob");}
+ trimDisk();
+ [pendingLock lock];pendingBytes-=bytes.length;[pendingLock unlock];
+ }});
+ }
+}
+static EGLsizeiANDROID getBlob(const void *key,EGLsizeiANDROID keySize,void *value,EGLsizeiANDROID valueSize) {
+ @autoreleasepool {
+ NSString *name=keyName(key,keySize);if(!name)return 0;
+ NSData *bytes=[memoryCache objectForKey:name];BOOL diskHit=NO;
+ if(!bytes) {
+ NSString *path=[cacheDirectory stringByAppendingPathComponent:[name stringByAppendingPathExtension:@"blob"]];
+ unsigned long long size=[[NSFileManager.defaultManager attributesOfItemAtPath:path error:nil] fileSize];
+ if(size==0 || size>maximumBlob)return 0;
+ bytes=[NSData dataWithContentsOfFile:path options:0 error:nil];
+ if(!bytes || bytes.length>maximumBlob)return 0;
+ [memoryCache setObject:bytes forKey:name cost:bytes.length];diskHit=YES;
+ }
+ if(value && valueSize>=0 && (NSUInteger)valueSize>=bytes.length) {
+ memcpy(value,bytes.bytes,bytes.length);
+
+ }
+ [pendingLock lock];BOOL log=diskHit && !loggedHit;if(diskHit)loggedHit=YES;[pendingLock unlock];
+ if(log)host_logf(HOST_LOG_INFO,"ANGLE shader cache: loaded compiled blob from disk");
+ return (EGLsizeiANDROID)bytes.length;
+ }
+}
+void halo_shader_cache_install(EGLDisplay display,PFNEGLGETPROCADDRESSPROC get_proc) {
+ PFNEGLQUERYSTRINGPROC query=(void *)get_proc("eglQueryString");
+ const char *extensions=query?query(display,EGL_EXTENSIONS):NULL;
+ PFNEGLSETBLOBCACHEFUNCSANDROIDPROC install=(void *)get_proc("eglSetBlobCacheFuncsANDROID");
+ if(!extensions || !strstr(extensions,"EGL_ANDROID_blob_cache") || !install) {
+ host_logf(HOST_LOG_INFO,"ANGLE shader cache: extension unavailable");return;
+ }
+ NSString *root=[NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask,YES).firstObject
+ stringByAppendingPathComponent:@"HaloShaderCache-v1"];
+ NSString *angle=NSBundle.mainBundle.privateFrameworksPath;
+ NSDictionary *version=[NSDictionary dictionaryWithContentsOfFile:[angle stringByAppendingPathComponent:@"libGLESv2.framework/Info.plist"]];
+ NSString *identity=[NSString stringWithFormat:@"%@|%@|%@|%@",MTLCreateSystemDefaultDevice().name,
+ NSProcessInfo.processInfo.operatingSystemVersionString,version[@"CFBundleVersion"],version[@"CFBundleShortVersionString"]];
+ cacheDirectory=[root stringByAppendingPathComponent:digest([identity dataUsingEncoding:NSUTF8StringEncoding])];
+ if(![NSFileManager.defaultManager createDirectoryAtPath:cacheDirectory withIntermediateDirectories:YES attributes:nil error:nil])return;
+ memoryCache=[NSCache new];memoryCache.totalCostLimit=maximumDisk;
+ pendingLock=[NSLock new];writer=dispatch_queue_create("org.haloce.shader-cache",DISPATCH_QUEUE_SERIAL);
+ dispatch_async(writer,^{trimDisk();});
+ install(display,storeBlob,getBlob);
+ host_logf(HOST_LOG_INFO,"ANGLE shader cache: enabled (64 MiB disk budget)");
+}
diff --git a/port/ios/host/host_syscall.c b/port/ios/host/host_syscall.c
new file mode 100644
index 0000000000..364df4364a
--- /dev/null
+++ b/port/ios/host/host_syscall.c
@@ -0,0 +1,200 @@
+/* Translate the guest's Linux/musl ABI into Darwin libc and pthread calls. */
+#include "ios_host.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "guest_syscall_numbers.h"
+
+#define P(type,x) ((type)host_pointer((uint64_t)(x)))
+struct guest_timespec { int32_t seconds, nanoseconds; };
+struct guest_iovec { uint32_t pointer, length; };
+struct guest_stat {
+ uint64_t device,inode;
+ uint32_t mode,links,uid,gid;
+ uint64_t rdev;
+ int64_t size;
+ int32_t block_size,padding;
+ int64_t blocks;
+ struct guest_timespec atime,mtime,ctime;
+};
+_Static_assert(sizeof(struct guest_stat)==88,"guest stat layout");
+
+/* Linux ILP32 sysinfo. Counts are pages to avoid truncating a device's RAM. */
+struct guest_sysinfo {
+ uint32_t uptime, loads[3], totalram, freeram, sharedram, bufferram;
+ uint32_t totalswap, freeswap;
+ uint16_t procs, pad;
+ uint32_t totalhigh, freehigh, mem_unit;
+ char reserved[256];
+};
+static long guest_sysinfo(uint64_t address) {
+ struct guest_sysinfo *info=P(struct guest_sysinfo *,address);
+ uint64_t memory=0; size_t size=sizeof(memory);
+ if(sysctlbyname("hw.memsize",&memory,&size,NULL,0))return -5;
+ memset(info,0,sizeof(*info));
+ struct timespec uptime;clock_gettime(CLOCK_MONOTONIC,&uptime);
+ info->uptime=(uint32_t)uptime.tv_sec;
+ info->mem_unit=(uint32_t)getpagesize();
+ info->totalram=(uint32_t)(memory/info->mem_unit);
+ vm_statistics64_data_t stats;mach_msg_type_number_t count=HOST_VM_INFO64_COUNT;
+ if(host_statistics64(mach_host_self(),HOST_VM_INFO64,(host_info64_t)&stats,&count)==KERN_SUCCESS)
+ info->freeram=stats.free_count;
+ return 0;
+}
+
+int host_linux_errno(int e) {
+ switch(e) {
+ case EAGAIN:return 11; case EDEADLK:return 35; case ENAMETOOLONG:return 36;
+ case ENOLCK:return 37; case ENOSYS:return 38; case ENOTEMPTY:return 39;
+ case ELOOP:return 40; case EOVERFLOW:return 75; case EILSEQ:return 84;
+ case ENOTSOCK:return 88; case EMSGSIZE:return 90; case ENOTSUP:return 95;
+ case EADDRINUSE:return 98; case ECONNRESET:return 104;
+ case ETIMEDOUT:return 110; case ECONNREFUSED:return 111;
+ case EINPROGRESS:return 115; case ECANCELED:return 125;
+ default:return e;
+ }
+}
+static long long result(long long n) { return n==-1 ? -host_linux_errno(errno) : n; }
+static int directory_fd(int n) { return n==-100 ? AT_FDCWD : n; }
+static int open_flags(int n) {
+ int out=n&3;
+ if(n&0100)out|=O_CREAT;if(n&0200)out|=O_EXCL;if(n&01000)out|=O_TRUNC;
+ if(n&02000)out|=O_APPEND;if(n&04000)out|=O_NONBLOCK;if(n&02000000)out|=O_CLOEXEC;
+ if(n&040000)out|=O_DIRECTORY;if(n&0100000)out|=O_NOFOLLOW;
+ return out;
+}
+static clockid_t clock_id(int n) { return n==0?CLOCK_REALTIME:CLOCK_MONOTONIC; }
+static struct timespec time_in(uint64_t p) {
+ const struct guest_timespec *t=P(const struct guest_timespec *,p);
+ return t?(struct timespec){t->seconds,t->nanoseconds}:(struct timespec){0,0};
+}
+static void time_out(uint64_t p,struct timespec t) {
+ struct guest_timespec *g=P(struct guest_timespec *,p);
+ if(g) {g->seconds=(int32_t)t.tv_sec;g->nanoseconds=(int32_t)t.tv_nsec;}
+}
+static void stat_out(uint64_t p,const struct stat *s) {
+ struct guest_stat *g=P(struct guest_stat *,p);
+ memset(g,0,sizeof(*g));g->device=s->st_dev;g->inode=s->st_ino;g->mode=s->st_mode;
+ g->links=s->st_nlink;g->uid=s->st_uid;g->gid=s->st_gid;g->rdev=s->st_rdev;
+ g->size=s->st_size;g->block_size=s->st_blksize;g->blocks=s->st_blocks;
+ g->atime=(struct guest_timespec){s->st_atimespec.tv_sec,s->st_atimespec.tv_nsec};
+ g->mtime=(struct guest_timespec){s->st_mtimespec.tv_sec,s->st_mtimespec.tv_nsec};
+ g->ctime=(struct guest_timespec){s->st_ctimespec.tv_sec,s->st_ctimespec.tv_nsec};
+}
+
+/* Each futex waiter has its own condition: hash collisions cannot consume
+ a wakeup intended for another guest address. */
+struct waiter { uint32_t address; int signaled; pthread_cond_t condition; struct waiter *next; };
+static struct waiter *waiters;
+static pthread_mutex_t futex_lock=PTHREAD_MUTEX_INITIALIZER;
+static long futex(uint32_t address,int operation,uint32_t value,uint64_t timeout) {
+ int command=operation&127;
+ pthread_mutex_lock(&futex_lock);
+ if(command==1 || command==10) {
+ unsigned woke=0;
+ for(struct waiter *w=waiters;w && wokenext)
+ if(w->address==address && !w->signaled) { w->signaled=1;pthread_cond_signal(&w->condition);woke++; }
+ pthread_mutex_unlock(&futex_lock);return woke;
+ }
+ if(command!=0 && command!=9) {pthread_mutex_unlock(&futex_lock);return -38;}
+ if(__atomic_load_n(P(uint32_t *,address),__ATOMIC_SEQ_CST)!=value) {pthread_mutex_unlock(&futex_lock);return -11;}
+ struct waiter w={.address=address,.next=waiters};pthread_cond_init(&w.condition,NULL);waiters=&w;
+ int error=0;struct timespec deadline;
+ if(timeout) {
+ struct timespec delta=time_in(timeout);clock_gettime(CLOCK_REALTIME,&deadline);
+ if(command==9) {
+ struct timespec now;clock_gettime((operation&256)?CLOCK_REALTIME:CLOCK_MONOTONIC,&now);
+ delta.tv_sec-=now.tv_sec;delta.tv_nsec-=now.tv_nsec;
+ }
+ deadline.tv_sec+=delta.tv_sec;deadline.tv_nsec+=delta.tv_nsec;
+ while(deadline.tv_nsec>=1000000000){deadline.tv_sec++;deadline.tv_nsec-=1000000000;}
+ while(deadline.tv_nsec<0){deadline.tv_sec--;deadline.tv_nsec+=1000000000;}
+ }
+ while(!w.signaled && !error) error=timeout?pthread_cond_timedwait(&w.condition,&futex_lock,&deadline):pthread_cond_wait(&w.condition,&futex_lock);
+ struct waiter **link=&waiters;while(*link!=&w)link=&(*link)->next;*link=w.next;
+ pthread_cond_destroy(&w.condition);pthread_mutex_unlock(&futex_lock);
+ return w.signaled?0:-host_linux_errno(error);
+}
+
+long long host_syscall(long long number,long long a,long long b,long long c,long long d,long long e,long long f) {
+ switch(number) {
+ case __NR_read:return result(read((int)a,P(void *,b),(uint32_t)c));
+ case __NR_write:return result(write((int)a,P(const void *,b),(uint32_t)c));
+ case __NR_openat:return result(openat(directory_fd(a),P(const char *,b),open_flags(c),(mode_t)d));
+ case __NR_close:return result(close(a));
+ case __NR_lseek:return result(lseek(a,b,c));
+ case __NR_pread64:return result(pread(a,P(void *,b),(uint32_t)c,d));
+ case __NR_pwrite64:return result(pwrite(a,P(const void *,b),(uint32_t)c,d));
+ case __NR_readv:case __NR_writev:case __NR_preadv:case __NR_pwritev: {
+ if(c<0 || c>64)return -22;
+ struct iovec vector[64];struct guest_iovec *g=P(struct guest_iovec *,b);
+ for(int i=0;i
+#include
+#include
+#include
+#include
+#include
+
+#define GUARD_SIZE 0x4000
+
+static __thread uint32_t guest_tp;
+
+uint32_t host_get_tp(void)
+{
+ return guest_tp;
+}
+
+void host_set_tp(uint32_t thread)
+{
+ guest_tp = thread;
+}
+
+/* ---------- stacks */
+
+static void *stack_allocate(size_t size, void **mapping, size_t *mapping_size)
+{
+ size_t total = size + GUARD_SIZE;
+ void *base = host_low_map(total, PROT_READ | PROT_WRITE);
+
+ if (!base)
+ return NULL;
+ /* guard page at the bottom */
+ mprotect(base, GUARD_SIZE, PROT_NONE);
+ *mapping = base;
+ *mapping_size = total;
+ return (char *)base + GUARD_SIZE;
+}
+
+static int on_guest_stack(void)
+{
+ uint64_t sp = (uint64_t)__builtin_frame_address(0);
+
+ return host_low_owns(sp, 1);
+}
+
+/* ---------- calling into the guest */
+
+typedef uint32_t (*guest_function)(uint32_t, uint32_t, uint32_t, uint32_t);
+
+uint32_t host_call_guest(uint32_t function, uint32_t a, uint32_t b, uint32_t c, uint32_t d)
+{
+ if (!on_guest_stack())
+ host_fatal("guest code called on a thread without a guest stack");
+ if (!guest_tp)
+ host_guest_invoke((uintptr_t)host_pointer(host_image.header->thread_attach), 0, 0, 0, 0, host_arena);
+ return host_guest_invoke((uintptr_t)host_pointer(function), a, b, c, d, host_arena);
+}
+
+void host_run_guest_main(uint32_t boot)
+{
+ host_guest_invoke((uintptr_t)host_pointer(host_image.header->start), boot, 0, 0, 0, host_arena);
+ host_fatal("the guest returned from __guest_start");
+}
+
+/* ---------- guest threads */
+
+struct thread_start
+{
+ void *(*function)(void *);
+ void *argument;
+ void *mapping;
+ size_t mapping_size;
+};
+
+struct finished_thread
+{
+ struct finished_thread *next;
+ pthread_t thread;
+ void *mapping;
+ size_t mapping_size;
+};
+
+static pthread_mutex_t reaper_lock = PTHREAD_MUTEX_INITIALIZER;
+static pthread_cond_t reaper_condition = PTHREAD_COND_INITIALIZER;
+static struct finished_thread *finished_threads;
+static int reaper_started;
+
+static void *reaper(void *unused)
+{
+ (void)unused;
+ for (;;)
+ {
+ struct finished_thread *finished;
+
+ pthread_mutex_lock(&reaper_lock);
+ while (!finished_threads)
+ pthread_cond_wait(&reaper_condition, &reaper_lock);
+ finished = finished_threads;
+ finished_threads = finished->next;
+ pthread_mutex_unlock(&reaper_lock);
+ pthread_join(finished->thread, NULL);
+ host_low_unmap(finished->mapping, finished->mapping_size);
+ free(finished);
+ }
+ return NULL;
+}
+
+static void *thread_main(void *context)
+{
+ struct thread_start start = *(struct thread_start *)context;
+ struct finished_thread *finished;
+
+ free(context);
+ host_debug_thread_started();
+ start.function(start.argument);
+ host_debug_thread_exited();
+ guest_tp = 0;
+
+ finished = calloc(1, sizeof(*finished));
+ finished->thread = pthread_self();
+ finished->mapping = start.mapping;
+ finished->mapping_size = start.mapping_size;
+ pthread_mutex_lock(&reaper_lock);
+ finished->next = finished_threads;
+ finished_threads = finished;
+ pthread_cond_signal(&reaper_condition);
+ pthread_mutex_unlock(&reaper_lock);
+ return NULL;
+}
+
+int host_native_thread_create(void *(*function)(void *), void *argument, size_t stack_size)
+{
+ struct thread_start *start = calloc(1, sizeof(*start));
+ pthread_attr_t attributes;
+ pthread_t thread;
+ void *stack;
+ int error;
+
+ if (!start)
+ return ENOMEM;
+ pthread_mutex_lock(&reaper_lock);
+ if (!reaper_started)
+ {
+ pthread_t reaper_thread;
+
+ if (pthread_create(&reaper_thread, NULL, reaper, NULL) == 0)
+ {
+ pthread_detach(reaper_thread);
+ reaper_started = 1;
+ }
+ }
+ pthread_mutex_unlock(&reaper_lock);
+
+ stack_size = (stack_size + 0xffff) & ~(size_t)0xffff;
+ stack = stack_allocate(stack_size, &start->mapping, &start->mapping_size);
+ if (!stack)
+ {
+ free(start);
+ return EAGAIN;
+ }
+ start->function = function;
+ start->argument = argument;
+ pthread_attr_init(&attributes);
+ pthread_attr_setstack(&attributes, stack, stack_size);
+ error = pthread_create(&thread, &attributes, thread_main, start);
+ pthread_attr_destroy(&attributes);
+ if (error)
+ {
+ host_low_unmap(start->mapping, start->mapping_size);
+ free(start);
+ }
+ return error;
+}
+
+static void *guest_thread_main(void *guest_thread)
+{
+ host_call_guest(host_image.header->thread_start, (uint32_t)(uintptr_t)guest_thread, 0, 0, 0);
+ return NULL;
+}
+
+int host_thread_create(uint32_t guest_thread, uint32_t stack_size)
+{
+ return host_native_thread_create(guest_thread_main, (void *)(uintptr_t)guest_thread, stack_size);
+}
diff --git a/port/ios/host/host_touch.m b/port/ios/host/host_touch.m
new file mode 100644
index 0000000000..9ed9391395
--- /dev/null
+++ b/port/ios/host/host_touch.m
@@ -0,0 +1,252 @@
+#include "host_debug.h"
+/* UIKit controls feed a standard SDL gamepad. The first hardware controller
+ shares player one with touch; additional hardware controllers keep their ports. */
+#import
+#include
+#include "ios_host.h"
+#import "host_orientation.h"
+#include
+
+static SDL_Joystick *touch_joystick;
+static SDL_JoystickID touch_id, primary_hardware;
+static SDL_Gamepad *primary_gamepad;
+static NSString *const controllerChanged = @"HaloControllerChanged";
+
+static void button_state(int button, BOOL down) {
+ if(touch_joystick) SDL_SetJoystickVirtualButton(touch_joystick,button,down);
+}
+static void axis_state(int axis, float value) {
+ if(touch_joystick) SDL_SetJoystickVirtualAxis(touch_joystick,axis,(Sint16)(fmaxf(-1,fminf(1,value))*32767));
+}
+void host_ios_touch_initialize(void) {
+ SDL_VirtualJoystickDesc desc;
+ SDL_INIT_INTERFACE(&desc);
+ desc.type=SDL_JOYSTICK_TYPE_GAMEPAD;
+ desc.naxes=SDL_GAMEPAD_AXIS_COUNT;
+ desc.nbuttons=SDL_GAMEPAD_BUTTON_COUNT;
+ desc.axis_mask=(1u<0 && touch_id)out[used++]=touch_id;
+ for(int i=0;iabs(value))value=physical;
+ }
+ return value;
+}
+int host_ios_gamepad_button(SDL_Gamepad *pad,int button) {
+ if(halo_debug_is_presented())return 0;
+ return SDL_GetGamepadButton(pad,button) ||
+ (SDL_GetGamepadID(pad)==touch_id && primary_hardware &&
+ SDL_GetGamepadButton(primary_gamepad,button));
+}
+void host_ios_touch_reset(void) {
+ for(int i=0;i=SDL_GAMEPAD_AXIS_LEFT_TRIGGER?-1:0);
+}
+
+@interface HaloButton : UIButton
+@property(nonatomic) int gameButton;
+@property(nonatomic) int gameAxis;
+@end
+@implementation HaloButton
+- (void)sendDown:(BOOL)down {
+ self.highlighted=down;
+ if(self.gameAxis>=0)axis_state(self.gameAxis,down?1:-1);
+ else button_state(self.gameButton,down);
+}
+- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
+ [super beginTrackingWithTouch:touch withEvent:event];[self sendDown:YES];return YES;
+}
+- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {
+ [super endTrackingWithTouch:touch withEvent:event];[self sendDown:NO];
+}
+- (void)cancelTrackingWithEvent:(UIEvent *)event {
+ [super cancelTrackingWithEvent:event];[self sendDown:NO];
+}
+- (BOOL)accessibilityActivate {
+ [self sendDown:YES];
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,150*NSEC_PER_MSEC),dispatch_get_main_queue(),^{[self sendDown:NO];});
+ return YES;
+}
+@end
+
+@interface HaloStick : UIControl
+@property(nonatomic) int firstAxis;
+@property(nonatomic) CGPoint position;
+@property(nonatomic,copy) NSString *caption;
+@end
+@implementation HaloStick
+- (void)updateTouch:(UITouch *)touch {
+ CGPoint p=[touch locationInView:self];CGFloat radius=self.bounds.size.width*.34;
+ CGFloat x=(p.x-self.bounds.size.width/2)/radius,y=(p.y-self.bounds.size.height/2)/radius;
+ CGFloat length=hypot(x,y);if(length>1){x/=length;y/=length;}
+ self.position=CGPointMake(x,y);axis_state(self.firstAxis,x);axis_state(self.firstAxis+1,y);
+ [self setNeedsDisplay];
+}
+- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self updateTouch:touch];return YES;}
+- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self updateTouch:touch];return YES;}
+- (void)reset {self.position=CGPointZero;axis_state(self.firstAxis,0);axis_state(self.firstAxis+1,0);[self setNeedsDisplay];}
+- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event {[self reset];}
+- (void)cancelTrackingWithEvent:(UIEvent *)event {[self reset];}
+- (void)drawRect:(CGRect)rect {
+ CGRect circle=CGRectInset(self.bounds,2,2);
+ [[UIColor colorWithWhite:0 alpha:.2]setFill];UIBezierPath *path=[UIBezierPath bezierPathWithOvalInRect:circle];[path fill];
+ [[UIColor colorWithWhite:1 alpha:.5]setStroke];path.lineWidth=1.5;[path stroke];
+ CGFloat size=self.bounds.size.width,r=size*.34;
+ CGRect knob=CGRectMake(size/2+self.position.x*r-21,size/2+self.position.y*r-21,42,42);
+ [[UIColor colorWithWhite:1 alpha:.25]setFill];[[UIBezierPath bezierPathWithOvalInRect:knob]fill];
+ NSDictionary *attrs=@{NSFontAttributeName:[UIFont systemFontOfSize:10 weight:UIFontWeightSemibold],NSForegroundColorAttributeName:[UIColor colorWithWhite:1 alpha:.8]};
+ CGSize text=[self.caption sizeWithAttributes:attrs];
+ [self.caption drawAtPoint:CGPointMake((size-text.width)/2,size-20) withAttributes:attrs];
+}
+@end
+
+@interface HaloControls : UIView
+@property(nonatomic,strong) HaloStick *moveStick;
+@property(nonatomic,strong) HaloStick *lookStick;
+@property(nonatomic,strong) NSMutableArray *buttons;
+@end
+static __weak HaloControls *activeControls;
+void host_ios_touch_focus(void) {[activeControls becomeFirstResponder];}
+@implementation HaloControls
+- (BOOL)canBecomeFirstResponder {return YES;}
+- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event {
+ if(motion==UIEventSubtypeMotionShake)halo_debug_present();
+ else [super motionEnded:motion withEvent:event];
+}
+- (void)openGraphicsDebug:(id)sender {(void)sender;halo_debug_present();}
+- (void)buildMenuWithBuilder:(id)builder {
+ [super buildMenuWithBuilder:builder];
+ if(builder.system==UIMenuSystem.mainSystem) {
+ UIKeyCommand *command=[UIKeyCommand commandWithTitle:@"Graphics Debug…" image:nil action:@selector(openGraphicsDebug:) input:@"d" modifierFlags:UIKeyModifierCommand|UIKeyModifierShift propertyList:nil];
+ [builder insertSiblingMenu:[UIMenu menuWithTitle:@"Debug" image:nil identifier:@"halo.graphics-debug" options:0 children:@[command]] beforeMenuForIdentifier:UIMenuWindow];
+ }
+}
+- (HaloButton *)addButton:(NSString *)title label:(NSString *)label button:(int)button axis:(int)axis {
+ HaloButton *b=[HaloButton buttonWithType:UIButtonTypeCustom];b.gameButton=button;b.gameAxis=axis;
+ [b setTitle:title forState:UIControlStateNormal];b.titleLabel.font=[UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
+ b.backgroundColor=[UIColor colorWithWhite:0 alpha:.28];b.layer.borderColor=[UIColor colorWithWhite:1 alpha:.5].CGColor;b.layer.borderWidth=1;
+ b.accessibilityLabel=label;b.exclusiveTouch=NO;[self addSubview:b];[self.buttons addObject:b];return b;
+}
+- (instancetype)initWithFrame:(CGRect)frame {
+ if(!(self=[super initWithFrame:frame]))return nil;
+ self.autoresizingMask=UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleHeight;
+ self.multipleTouchEnabled=YES;self.buttons=[NSMutableArray array];
+ self.moveStick=[[HaloStick alloc]init];self.moveStick.firstAxis=SDL_GAMEPAD_AXIS_LEFTX;self.moveStick.caption=@"MOVE";
+ self.lookStick=[[HaloStick alloc]init];self.lookStick.firstAxis=SDL_GAMEPAD_AXIS_RIGHTX;self.lookStick.caption=@"LOOK";
+ for(HaloStick *s in @[self.moveStick,self.lookStick]){s.backgroundColor=UIColor.clearColor;s.isAccessibilityElement=YES;s.accessibilityLabel=s.caption;[self addSubview:s];}
+ [self addButton:@"A" label:@"A — Jump or select" button:SDL_GAMEPAD_BUTTON_SOUTH axis:-1];
+ [self addButton:@"B" label:@"B — Melee or back" button:SDL_GAMEPAD_BUTTON_EAST axis:-1];
+ [self addButton:@"X" label:@"X — Reload or use" button:SDL_GAMEPAD_BUTTON_WEST axis:-1];
+ [self addButton:@"Y" label:@"Y — Switch weapon" button:SDL_GAMEPAD_BUTTON_NORTH axis:-1];
+ [self addButton:@"FIRE" label:@"Fire" button:0 axis:SDL_GAMEPAD_AXIS_RIGHT_TRIGGER];
+ [self addButton:@"GRENADE" label:@"Throw grenade" button:0 axis:SDL_GAMEPAD_AXIS_LEFT_TRIGGER];
+ [self addButton:@"↑" label:@"Menu up" button:SDL_GAMEPAD_BUTTON_DPAD_UP axis:-1];
+ [self addButton:@"↓" label:@"Menu down" button:SDL_GAMEPAD_BUTTON_DPAD_DOWN axis:-1];
+ [self addButton:@"←" label:@"Menu left" button:SDL_GAMEPAD_BUTTON_DPAD_LEFT axis:-1];
+ [self addButton:@"→" label:@"Menu right" button:SDL_GAMEPAD_BUTTON_DPAD_RIGHT axis:-1];
+ [self addButton:@"PAUSE" label:@"Pause or start" button:SDL_GAMEPAD_BUTTON_START axis:-1];
+ [self addButton:@"CROUCH" label:@"Crouch" button:SDL_GAMEPAD_BUTTON_LEFT_STICK axis:-1];
+ [self addButton:@"ZOOM" label:@"Zoom" button:SDL_GAMEPAD_BUTTON_RIGHT_STICK axis:-1];
+ [self addButton:@"LIGHT" label:@"Flashlight" button:SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER axis:-1];
+ [self addButton:@"SWAP G" label:@"Switch grenade" button:SDL_GAMEPAD_BUTTON_LEFT_SHOULDER axis:-1];
+ [[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(reset) name:UIApplicationWillResignActiveNotification object:nil];
+ [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(controllerChanged) name:controllerChanged object:nil];
+ [self controllerChanged];
+ return self;
+}
+- (void)controllerChanged {
+ BOOL connected=primary_hardware!=0;
+ [self setControlsHidden:connected];
+}
+- (void)setControlsHidden:(BOOL)hidden {
+ [self reset];
+ self.moveStick.hidden=self.lookStick.hidden=hidden;
+ for(UIView *button in self.buttons)button.hidden=hidden;
+}
+- (void)reset {host_ios_touch_reset();[self.moveStick reset];[self.lookStick reset];}
+- (void)layoutSubviews {
+ [super layoutSubviews];CGRect r=UIEdgeInsetsInsetRect(self.bounds,self.safeAreaInsets);
+ CGFloat left=r.origin.x+16,right=CGRectGetMaxX(r)-16,bottom=CGRectGetMaxY(r)-12,top=r.origin.y+10;
+ CGFloat stick=MIN(136,r.size.height*.32),s=44,gap=4;
+ self.moveStick.frame=CGRectMake(left,bottom-stick,stick,stick);
+ self.lookStick.frame=CGRectMake(right-3*s-20-stick,bottom-stick,stick,stick);
+ // Four face buttons in the traditional diamond.
+ CGPoint face[4]={{right-2*s-gap,bottom-s},{right-s,bottom-2*s-gap},{right-3*s-2*gap,bottom-2*s-gap},{right-2*s-gap,bottom-3*s-2*gap}};
+ for(int i=0;i<4;i++)self.buttons[i].frame=CGRectMake(face[i].x,face[i].y,s,s);
+ self.buttons[4].frame=CGRectMake(right-84,top+55,84,48);
+ self.buttons[5].frame=CGRectMake(left,top+55,90,48);
+ CGFloat d=34,dx=left+stick+12,dy=bottom-3*d;
+ self.buttons[6].frame=CGRectMake(dx+d,dy,d,d);
+ self.buttons[7].frame=CGRectMake(dx+d,dy+2*d,d,d);
+ self.buttons[8].frame=CGRectMake(dx,dy+d,d,d);
+ self.buttons[9].frame=CGRectMake(dx+2*d,dy+d,d,d);
+ self.buttons[10].frame=CGRectMake(CGRectGetMidX(r)-38,top,76,34);
+ self.buttons[11].frame=CGRectMake(left,bottom-stick-42,74,34);
+ self.buttons[12].frame=CGRectMake(right-3*s-20-stick,bottom-stick-42,74,34);
+ self.buttons[13].frame=CGRectMake(right-180,top+55,80,36);
+ self.buttons[14].frame=CGRectMake(left+104,top+55,80,36);
+ for(UIButton *b in self.buttons){b.layer.cornerRadius=MIN(b.bounds.size.width,b.bounds.size.height)/2;b.titleLabel.adjustsFontSizeToFitWidth=YES;}
+}
+- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
+ for(UIView *view in self.subviews)if(!view.hidden && [view pointInside:[self convertPoint:point toView:view] withEvent:event])return YES;
+ return NO;
+}
+@end
+
+void host_ios_touch_attach(SDL_Window *window) {
+ UIWindow *native=(__bridge UIWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window),SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER,NULL);
+ /* Retire SDL's temporary launch window before entering the non-returning
+ game loop. Its fade animation can otherwise keep accessibility/input
+ attached to the launch controller on recent scene-based iOS versions. */
+ for(UIWindow *candidate in native.windowScene.windows) {
+ if(candidate!=native && [NSStringFromClass(candidate.rootViewController.class) hasPrefix:@"SDLLaunch"])
+ candidate.hidden=YES;
+ }
+ [native makeKeyAndVisible];
+ host_ios_require_landscape(native);
+ UIView *root=native.rootViewController.view;
+ HaloControls *controls=[[HaloControls alloc]initWithFrame:root.bounds];
+ [root addSubview:controls];
+ activeControls=controls;
+ [controls becomeFirstResponder];
+ [UIMenuSystem.mainSystem setNeedsRebuild];
+ if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_DEBUG_UI"] boolValue])
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW,NSEC_PER_SEC*3),dispatch_get_main_queue(),^{halo_debug_present();});
+ host_logf(HOST_LOG_INFO,"touch controls attached, %.0fx%.0f",root.bounds.size.width,root.bounds.size.height);
+}
diff --git a/port/ios/host/ios_host.h b/port/ios/host/ios_host.h
new file mode 100644
index 0000000000..6330044bec
--- /dev/null
+++ b/port/ios/host/ios_host.h
@@ -0,0 +1,27 @@
+/* iOS host services for the ILP32 guest in an aligned native address arena. */
+#ifndef HALO_IOS_HOST_H
+#define HALO_IOS_HOST_H
+#include "host.h"
+#include
+extern uintptr_t host_arena;
+static inline void *host_pointer(uint64_t p) { return p ? (void *)(host_arena | (uint32_t)p) : NULL; }
+static inline uint32_t guest_pointer(const void *p) { return (uint32_t)(uintptr_t)p; }
+uint32_t host_ios_default_framebuffer(void);
+struct SDL_Window;
+struct SDL_Gamepad;
+void host_ios_touch_initialize(void);
+void host_ios_prepare_assets(const char *documents);
+/* tvOS: receive the player's XISO over the local network into /maps. */
+void host_tv_import(const char *documents);
+void host_ios_touch_attach(struct SDL_Window *window);
+void host_ios_touch_reset(void);
+void host_ios_touch_focus(void);
+int host_ios_gamepads(uint32_t *out, int capacity);
+int host_ios_gamepad_type(struct SDL_Gamepad *pad);
+int host_ios_gamepad_axis(struct SDL_Gamepad *pad, int axis);
+int host_ios_gamepad_button(struct SDL_Gamepad *pad, int button);
+int host_linux_errno(int native_errno);
+void host_install_signal_handlers(void);
+uint32_t host_guest_invoke(uintptr_t function, uint32_t a, uint32_t b, uint32_t c, uint32_t d, uintptr_t arena);
+uint32_t host_guest_on_stack(uintptr_t function, uint32_t argument, uintptr_t arena, void *stack_top);
+#endif
diff --git a/port/ios/host/posix_net.c b/port/ios/host/posix_net.c
new file mode 100644
index 0000000000..1b064eced0
--- /dev/null
+++ b/port/ios/host/posix_net.c
@@ -0,0 +1,105 @@
+/* Darwin implementation behind the native / WebRTC transport selection. */
+#define posix_socket_last_error ios_native_posix_socket_last_error
+#define posix_socket ios_native_posix_socket
+#define posix_socket_close ios_native_posix_socket_close
+#define posix_socket_bind ios_native_posix_socket_bind
+#define posix_socket_listen ios_native_posix_socket_listen
+#define posix_socket_connect ios_native_posix_socket_connect
+#define posix_socket_accept ios_native_posix_socket_accept
+#define posix_socket_send ios_native_posix_socket_send
+#define posix_socket_sendto ios_native_posix_socket_sendto
+#define posix_socket_recv ios_native_posix_socket_recv
+#define posix_socket_recvfrom ios_native_posix_socket_recvfrom
+#define posix_socket_shutdown ios_native_posix_socket_shutdown
+#define posix_socket_set_nonblocking ios_native_posix_socket_set_nonblocking
+#define posix_socket_set_nodelay ios_native_posix_socket_set_nodelay
+#define posix_socket_bytes_available ios_native_posix_socket_bytes_available
+#define posix_socket_setsockopt ios_native_posix_socket_setsockopt
+#define posix_socket_getsockopt ios_native_posix_socket_getsockopt
+#define posix_socket_getsockname ios_native_posix_socket_getsockname
+#define posix_socket_getpeername ios_native_posix_socket_getpeername
+#define posix_socket_select ios_native_posix_socket_select
+#define posix_local_ipv4_address ios_native_posix_local_ipv4_address
+#define posix_resolve_ipv4 ios_native_posix_resolve_ipv4
+/* Adapt Linux socket helpers to Darwin's sockaddr length byte and socket flags. */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifndef SOCK_CLOEXEC
+#define SOCK_CLOEXEC 0x80000
+#endif
+#ifndef MSG_NOSIGNAL
+#define MSG_NOSIGNAL 0
+#endif
+static struct sockaddr_storage sockaddr_inward(const void *input,socklen_t size) {
+ struct sockaddr_storage address={0};
+ if(size>sizeof(address))size=sizeof(address);
+ if(input && size>=2){memcpy(&address,input,size);uint16_t family;memcpy(&family,input,2);address.ss_len=size;address.ss_family=family;}
+ return address;
+}
+static void sockaddr_outward(void *address,socklen_t size) {
+ if(address && size>=2){uint16_t family=((struct sockaddr *)address)->sa_family;memcpy(address,&family,2);}
+}
+static int ios_socket(int family,int type,int protocol) {
+ int fd=socket(family,type&~SOCK_CLOEXEC,protocol);
+ if(fd>=0){int one=1;setsockopt(fd,SOL_SOCKET,SO_NOSIGPIPE,&one,sizeof(one));fcntl(fd,F_SETFD,FD_CLOEXEC);}return fd;
+}
+static int ios_bind(int fd,const void *p,socklen_t n) {struct sockaddr_storage a=sockaddr_inward(p,n);return bind(fd,(struct sockaddr *)&a,n);}
+static int ios_connect(int fd,const void *p,socklen_t n) {struct sockaddr_storage a=sockaddr_inward(p,n);return connect(fd,(struct sockaddr *)&a,n);}
+static int ios_accept4(int fd,void *p,socklen_t *n,int flags) {
+ (void)flags;int out=accept(fd,p,n);if(out>=0){fcntl(out,F_SETFD,FD_CLOEXEC);if(n)sockaddr_outward(p,*n);}return out;
+}
+static ssize_t ios_sendto(int fd,const void *p,size_t n,int flags,const void *address,socklen_t size) {
+ struct sockaddr_storage a=sockaddr_inward(address,size);return sendto(fd,p,n,flags,(struct sockaddr *)&a,size);
+}
+static ssize_t ios_recvfrom(int fd,void *p,size_t n,int flags,void *address,socklen_t *size) {
+ ssize_t out=recvfrom(fd,p,n,flags,address,size);if(out>=0 && size)sockaddr_outward(address,*size);return out;
+}
+/* The shared helper uses recvmsg to detect truncated datagrams, not recvfrom. */
+static ssize_t ios_recvmsg(int fd,struct msghdr *message,int flags) {
+ ssize_t result=recvmsg(fd,message,flags);
+ if(result>=0)sockaddr_outward(message->msg_name,message->msg_namelen);
+ return result;
+}
+static int ios_getsockname(int fd,void *p,socklen_t *n) {int r=getsockname(fd,p,n);if(!r)sockaddr_outward(p,*n);return r;}
+static int ios_getpeername(int fd,void *p,socklen_t *n) {int r=getpeername(fd,p,n);if(!r)sockaddr_outward(p,*n);return r;}
+static ssize_t ios_getrandom(void *p,size_t size,unsigned flags) {(void)flags;arc4random_buf(p,size);return size;}
+#define socket ios_socket
+#define bind ios_bind
+#define connect ios_connect
+#define accept4 ios_accept4
+#define sendto ios_sendto
+#define recvfrom ios_recvfrom
+#define recvmsg ios_recvmsg
+#define getsockname ios_getsockname
+#define getpeername ios_getpeername
+#define getrandom ios_getrandom
+#include "../../linux/src/posix_net.c"
+
+/* Native clients use upstream STUN/hole punching. Router forwarding is not enabled. */
+int posix_upnp_forward_udp(unsigned short port, unsigned short preferred_port,
+ posix_ulong *address, unsigned short *external_port, char *error, int error_size)
+{
+ (void)port; (void)preferred_port;
+ if (address) *address = 0;
+ if (external_port) *external_port = 0;
+ if (error && error_size > 0) snprintf(error, (size_t)error_size, "Router port forwarding is unavailable on this Apple build");
+ return 0;
+}
+void posix_upnp_stop_forwarding_udp(unsigned short port) { (void)port; }
diff --git a/port/ios/host_imports.list b/port/ios/host_imports.list
new file mode 100644
index 0000000000..b22da71253
--- /dev/null
+++ b/port/ios/host_imports.list
@@ -0,0 +1,9 @@
+host_apple_poll_event
+host_ios_browser_mode
+host_ios_browser_open
+host_ios_browser_update
+host_ios_browser_close
+host_ios_browser_error
+host_sdl_scancode_name
+host_sdl_scancode_from_name
+host_apple_request_quit
diff --git a/port/ios/network/guest_room.c b/port/ios/network/guest_room.c
new file mode 100644
index 0000000000..119065e95f
--- /dev/null
+++ b/port/ios/network/guest_room.c
@@ -0,0 +1,65 @@
+/* Native ARM guest adapter for the browser engine's quick-play controller. */
+#include "guest_host.h"
+#include
+#include "p2p.h"
+#include "posix.h"
+#include
+#include "../../web/src/web_quick_play.c"
+double emscripten_get_now(void) {
+ struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now);
+ return now.tv_sec * 1000.0 + now.tv_nsec / 1000000.0;
+}
+void web_js_post(int kind, const char *text) { if (kind == 6) host_ios_room_report(text); }
+int web_net_host_ping(unsigned int address, unsigned int host, unsigned int epoch) {
+ return host_ios_room_ping(address, host, epoch);
+}
+void ios_quick_play_reset(void);
+void ios_quick_play_begin(int host, unsigned long target, unsigned int epoch);
+void ios_room_open(void) {
+ web_quick_play_cancel();web_quick_play_take_cancel();
+ ios_quick_play_reset();
+ if(host_ios_browser_mode()) { host_ios_room_open(); return; }
+ p2p_initialize(posix_local_ipv4_address());
+ p2p_lobby_browse(1);
+ host_ios_browser_open();
+ struct p2p_listing pending; int connecting=0, started=0; double deadline=0;
+ for (;;) {
+ struct p2p_listing listings[256], chosen;
+ if(connecting) {
+ unsigned long address=0;
+ if(p2p_peer_address(pending.identifier,&address)) { started=1; ios_quick_play_begin(0,quick_play_engine_address(address),0); break; }
+ if(emscripten_get_now()>deadline) { connecting=0; p2p_lobby_mark_failed(pending.identifier); host_ios_browser_error("The server did not respond. It may be offline or unreachable from this network."); }
+ }
+ int count=p2p_lobby_games(listings,256);
+ int action=host_ios_browser_update(listings,count,&chosen);
+ if(action==2) { p2p_lobby_refresh(); continue; }
+ if(action<0)break;
+ if(action==1) {
+ if(strstr(chosen.map,"@ce")) {host_ios_browser_error("This server requires a Halo PC / Custom Edition map, which this build cannot load.");continue;}
+ if(!chosen.open || chosen.player_count>=chosen.maximum_player_count) {
+ host_ios_browser_error("This server is full or is not accepting players."); continue;
+ }
+ if(!p2p_join_invite(chosen.invite)) {
+ host_ios_browser_error("Could not connect to this server's invite. Refresh and try again."); continue;
+ }
+ pending=chosen;connecting=1;deadline=emscripten_get_now()+60000;
+ }
+ struct timespec pause={0,10000000};nanosleep(&pause,0);
+ }
+ p2p_lobby_browse(0);
+ host_ios_browser_close(started);
+}
+void ios_room_pump(void) {
+ unsigned int c[4];
+ for (int count = 0; count < 32 && host_ios_room_poll(c); count++) {
+ switch (c[0]) {
+ case 1: web_quick_play_set_address(c[1]); web_quick_play_set_epoch(c[2]); break;
+ case 2: web_quick_play_hold(c[1]); break;
+ case 3: web_quick_play_migrate(c[1], c[2], c[3]); break;
+ case 4: web_quick_play_reconnect(c[1], c[2]); break;
+ case 5: web_quick_play_cancel(); break;
+ case 7: { void ios_browser_test_open(void); ios_browser_test_open(); break; }
+ case 6: ios_quick_play_begin(c[1]==1,quick_play_engine_address(c[2]),c[3]); break;
+ }
+ }
+}
diff --git a/port/ios/network/host_browser.m b/port/ios/network/host_browser.m
new file mode 100644
index 0000000000..2648451546
--- /dev/null
+++ b/port/ios/network/host_browser.m
@@ -0,0 +1,209 @@
+/* Native presentation of upstream c04765d7's signed public server listings.
+ Discovery, invite validation and NAT traversal remain in the shared P2P code. */
+#if HALO_MACOS
+#import
+@interface NSTextField (HaloText)
+@property(nonatomic,copy) NSString *text;
+@end
+#else
+#import
+#endif
+#import
+#include "p2p.h"
+#include "network_mode.h"
+#include "room_bridge.h"
+#include "../host/ios_host.h"
+
+static NSMutableArray *rows;
+static struct p2p_listing selection;
+static int action;
+static BOOL connecting, progressMode;
+void host_ios_browser_close(int joining);
+void host_ios_browser_error(const char *text);
+static double refreshed;
+static NSString *lastSummary;
+#if HALO_MACOS
+static NSWindow *browserWindow, *previousWindow;
+@interface HaloServerBrowser : NSObject
+@property NSTableView *table;
+@property NSTextField *status;
+@end
+#else
+static UIWindow *browserWindow, *previousWindow;
+@interface HaloServerBrowser : UITableViewController
+@property UILabel *status;
+@end
+#endif
+static HaloServerBrowser *browser;
+static NSString *field(const char *value,size_t size) {
+ return [[NSString alloc]initWithBytes:value length:strnlen(value,size) encoding:NSASCIIStringEncoding]?:@"";
+}
+static NSString *serverTitle(const struct p2p_listing *game) {
+ return [NSString stringWithFormat:@"%@ %u/%u",field(game->name,sizeof(game->name)),game->player_count,game->maximum_player_count];
+}
+static NSString *serverDetail(const struct p2p_listing *game) {
+ NSString *ping=game->ping<0?@"Ping unknown":[NSString stringWithFormat:@"%d ms",game->ping];
+ return [NSString stringWithFormat:@"%@ · %@ · %@%@",field(game->map,sizeof(game->map)),field(game->gametype,sizeof(game->gametype)),ping,(!game->open||game->player_count>=game->maximum_player_count)?@" · Unavailable":@""];
+}
+static void choose(NSInteger index) {
+ if(connecting || index<0 || index>=(NSInteger)rows.count)return;
+ [rows[index] getBytes:&selection length:sizeof(selection)];action=1;connecting=YES;browser.status.text=@"Connecting to server… Cancel to return.";
+}
+@implementation HaloServerBrowser
+- (void)refresh {action=2;self.status.text=@"Refreshing public servers…";}
+- (void)cancel {
+ if(progressMode) {
+ const uint32_t command[4]={5,0,0,0};ios_room_queue_command(command);
+ host_ios_browser_close(0);
+ } else action=-1;
+}
+#if HALO_MACOS
+- (NSInteger)numberOfRowsInTableView:(NSTableView *)table {(void)table;return rows.count;}
+- (id)tableView:(NSTableView *)table objectValueForTableColumn:(NSTableColumn *)column row:(NSInteger)row {
+ (void)table;const struct p2p_listing *game=rows[row].bytes;
+ return [column.identifier isEqualToString:@"server"]?serverTitle(game):serverDetail(game);
+}
+- (void)join {choose(self.table.selectedRow);}
+#else
+- (void)viewDidLoad {
+ [super viewDidLoad];self.title=@"Public servers";
+ self.navigationItem.leftBarButtonItem=[[UIBarButtonItem alloc]initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(cancel)];
+ self.navigationItem.rightBarButtonItem=[[UIBarButtonItem alloc]initWithBarButtonSystemItem:UIBarButtonSystemItemRefresh target:self action:@selector(refresh)];
+ self.status=[[UILabel alloc]initWithFrame:CGRectMake(0,0,640,64)];self.status.numberOfLines=0;self.status.textAlignment=NSTextAlignmentCenter;self.status.font=[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
+ self.status.text=@"Looking for compatible public servers…\nTap a server to join.";self.tableView.tableHeaderView=self.status;
+}
+- (NSInteger)tableView:(UITableView *)table numberOfRowsInSection:(NSInteger)section {(void)table;(void)section;return rows.count;}
+- (UITableViewCell *)tableView:(UITableView *)table cellForRowAtIndexPath:(NSIndexPath *)path {
+ (void)table;UITableViewCell *cell=[[UITableViewCell alloc]initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:nil];
+ const struct p2p_listing *game=rows[path.row].bytes;
+ cell.textLabel.text=serverTitle(game);cell.detailTextLabel.text=serverDetail(game);cell.detailTextLabel.numberOfLines=0;
+ cell.accessoryType=UITableViewCellAccessoryDisclosureIndicator;return cell;
+}
+- (void)tableView:(UITableView *)table didSelectRowAtIndexPath:(NSIndexPath *)path {[table deselectRowAtIndexPath:path animated:YES];choose(path.row);}
+#endif
+@end
+
+int host_ios_browser_mode(void) {return halo_browser_rooms;}
+void host_ios_browser_open(void) {
+ extern void host_ios_room_test_navigation_consumed(void);
+ host_ios_room_test_navigation_consumed();
+ action=0;connecting=NO;progressMode=NO;refreshed=0;lastSummary=nil;rows=[NSMutableArray new];host_ios_touch_reset();
+ browser=[HaloServerBrowser new];
+#if HALO_MACOS
+ previousWindow=NSApp.keyWindow;
+ browserWindow=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,840,440) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];browserWindow.title=@"Public servers";
+ browser.status=[NSTextField wrappingLabelWithString:@"Looking for compatible public servers…"];browser.status.frame=NSMakeRect(16,386,808,38);[browserWindow.contentView addSubview:browser.status];
+ NSScrollView *scroll=[[NSScrollView alloc]initWithFrame:NSMakeRect(16,60,808,320)];scroll.hasVerticalScroller=YES;
+ browser.table=[[NSTableView alloc]initWithFrame:scroll.bounds];
+ for(NSString *identifier in @[@"server",@"details"]) {NSTableColumn *column=[[NSTableColumn alloc]initWithIdentifier:identifier];column.title=[identifier isEqualToString:@"server"]?@"Server / Players":@"Map / Game / Ping";column.width=[identifier isEqualToString:@"server"]?280:500;[browser.table addTableColumn:column];}
+ browser.table.dataSource=browser;browser.table.delegate=browser;browser.table.target=browser;browser.table.doubleAction=@selector(join);scroll.documentView=browser.table;[browserWindow.contentView addSubview:scroll];
+ NSArray *titles=@[@"Cancel",@"Refresh",@"Join server"];SEL selectors[]={@selector(cancel),@selector(refresh),@selector(join)};
+ for(int i=0;i<3;i++){NSButton *button=[NSButton buttonWithTitle:titles[i] target:browser action:selectors[i]];button.frame=NSMakeRect(16+i*140,14,130,32);[browserWindow.contentView addSubview:button];}
+ [browserWindow center];[browserWindow makeKeyAndOrderFront:nil];
+#else
+ UIWindowScene *scene=nil;
+ for(UIScene *candidate in UIApplication.sharedApplication.connectedScenes)if([candidate isKindOfClass:UIWindowScene.class] && candidate.activationState==UISceneActivationStateForegroundActive){scene=(UIWindowScene *)candidate;break;}
+ if(!scene){action=-1;return;}
+ for(UIWindow *window in scene.windows)if(window.isKeyWindow)previousWindow=window;
+ browserWindow=[[UIWindow alloc]initWithWindowScene:scene];browserWindow.windowLevel=UIWindowLevelNormal+1;
+ browserWindow.rootViewController=[[UINavigationController alloc]initWithRootViewController:browser];[browserWindow makeKeyAndVisible];[browser loadViewIfNeeded];
+#endif
+}
+int host_ios_browser_update(const void *listings,int count,void *chosen) {
+ if(count<0 || count>256 || (count && !listings))return -1;
+ if(CACurrentMediaTime()-refreshed>.5 && !action) {
+ refreshed=CACurrentMediaTime();NSMutableArray *next=[NSMutableArray new];
+ for(int i=0;iname,sizeof(game->name)) isEqualToString:testName]) {testJoined=YES;choose(i);break;}
+ }
+ }
+ int result=action;action=0;
+ if(result==1){memcpy(chosen,&selection,sizeof(selection));host_logf(HOST_LOG_INFO,"Joining public server: %s",serverTitle(&selection).UTF8String);}
+ return result;
+}
+void host_ios_browser_close(int joining) {
+ if(joining && browserWindow) {
+ progressMode=YES;
+ rows=[NSMutableArray new];
+ browser.status.text=@"Joining server… Keep Halo open while it connects.";
+#if HALO_MACOS
+ browserWindow.title=@"Joining server";
+ [browser.table reloadData];browser.table.enclosingScrollView.hidden=YES;
+ for(NSView *view in browserWindow.contentView.subviews)if([view isKindOfClass:NSButton.class] && ![((NSButton *)view).title isEqualToString:@"Cancel"])view.hidden=YES;
+ [previousWindow makeKeyAndOrderFront:nil];[browserWindow orderFront:nil];
+#else
+ browser.title=@"Joining server";
+ browser.navigationItem.rightBarButtonItem=nil;
+ [browser.tableView reloadData];
+ UIActivityIndicatorView *spinner=[[UIActivityIndicatorView alloc]initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
+ [spinner startAnimating];browser.tableView.backgroundView=spinner;
+ // Keep the SDL window key (and its game loop running), while the
+ // non-key progress window covers the menus and handles Cancel.
+ [previousWindow makeKeyAndVisible];browserWindow.hidden=NO;
+#endif
+ host_logf(HOST_LOG_INFO,"Server join progress visible");
+ return;
+ }
+ progressMode=NO;connecting=NO;
+#if HALO_MACOS
+ [browserWindow orderOut:nil];[previousWindow makeKeyAndOrderFront:nil];
+#else
+ browserWindow.hidden=YES;[previousWindow makeKeyAndVisible];
+#endif
+ browserWindow=nil;previousWindow=nil;browser=nil;rows=nil;host_ios_touch_reset();
+}
+void host_ios_browser_error(const char *text) {
+ connecting=NO;
+ NSString *message=[NSString stringWithUTF8String:text]?:@"Could not join the server.";
+ host_logf(HOST_LOG_ERROR,"Multiplayer: %s",message.UTF8String);
+ dispatch_async(dispatch_get_main_queue(), ^{
+#if HALO_MACOS
+ NSAlert *alert=[NSAlert new];alert.messageText=@"Multiplayer";alert.informativeText=message;[alert addButtonWithTitle:@"OK"];
+ NSWindow *window=browserWindow?:NSApp.keyWindow;if(window && !window.attachedSheet)[alert beginSheetModalForWindow:window completionHandler:nil];
+#else
+ UIWindow *window=browserWindow;
+ if(!window)for(UIScene *scene in UIApplication.sharedApplication.connectedScenes)if([scene isKindOfClass:UIWindowScene.class])for(UIWindow *w in ((UIWindowScene *)scene).windows)if(w.isKeyWindow)window=w;
+ UIViewController *presenter=window.rootViewController;while(presenter.presentedViewController)presenter=presenter.presentedViewController;
+ if([presenter isKindOfClass:UIAlertController.class])return;
+ UIAlertController *alert=[UIAlertController alertControllerWithTitle:@"Multiplayer" message:message preferredStyle:UIAlertControllerStyleAlert];[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];[presenter presentViewController:alert animated:YES completion:nil];
+#endif
+ });
+}
+
+/* Called on the guest main thread; this updates presentation without running
+ a nested game loop. The engine continues precaching and exchanging packets. */
+void host_ios_browser_status(const char *phaseText,const char *messageText) {
+ NSString *phase=[NSString stringWithUTF8String:phaseText?:""];
+ NSString *message=[NSString stringWithUTF8String:messageText?:""];
+ if(progressMode) {
+ browser.status.text=message;
+ if([phase isEqualToString:@"playing"]) {
+ host_logf(HOST_LOG_INFO,"Server join progress dismissed: gameplay ready");
+ host_ios_browser_close(0);
+ } else if([phase isEqualToString:@"error"] || [phase isEqualToString:@"menu"] || [phase isEqualToString:@"disconnected"]) {
+ host_ios_browser_close(0);
+ host_ios_browser_error(([phase isEqualToString:@"menu"]?@"The connection closed before the game started. Please choose another server or try again.":message).UTF8String);
+ }
+ } else if([phase isEqualToString:@"error"] || [phase isEqualToString:@"disconnected"])host_ios_browser_error(message.UTF8String);
+}
diff --git a/port/ios/network/host_net.c b/port/ios/network/host_net.c
new file mode 100644
index 0000000000..0a750b9446
--- /dev/null
+++ b/port/ios/network/host_net.c
@@ -0,0 +1,69 @@
+#include "posix.h"
+#include "network_mode.h"
+int halo_browser_rooms;
+int ios_native_posix_socket_last_error(void);
+int ios_web_posix_socket_last_error(void);
+int posix_socket_last_error(void) { return halo_browser_rooms ? ios_web_posix_socket_last_error() : ios_native_posix_socket_last_error(); }
+int ios_native_posix_socket(int family, int type, int protocol);
+int ios_web_posix_socket(int family, int type, int protocol);
+int posix_socket(int family, int type, int protocol) { return halo_browser_rooms ? ios_web_posix_socket(family, type, protocol) : ios_native_posix_socket(family, type, protocol); }
+int ios_native_posix_socket_close(int socket);
+int ios_web_posix_socket_close(int socket);
+int posix_socket_close(int socket) { return halo_browser_rooms ? ios_web_posix_socket_close(socket) : ios_native_posix_socket_close(socket); }
+int ios_native_posix_socket_bind(int socket, const void *address, int address_length);
+int ios_web_posix_socket_bind(int socket, const void *address, int address_length);
+int posix_socket_bind(int socket, const void *address, int address_length) { return halo_browser_rooms ? ios_web_posix_socket_bind(socket, address, address_length) : ios_native_posix_socket_bind(socket, address, address_length); }
+int ios_native_posix_socket_connect(int socket, const void *address, int address_length);
+int ios_web_posix_socket_connect(int socket, const void *address, int address_length);
+int posix_socket_connect(int socket, const void *address, int address_length) { return halo_browser_rooms ? ios_web_posix_socket_connect(socket, address, address_length) : ios_native_posix_socket_connect(socket, address, address_length); }
+int ios_native_posix_socket_listen(int socket, int backlog);
+int ios_web_posix_socket_listen(int socket, int backlog);
+int posix_socket_listen(int socket, int backlog) { return halo_browser_rooms ? ios_web_posix_socket_listen(socket, backlog) : ios_native_posix_socket_listen(socket, backlog); }
+int ios_native_posix_socket_accept(int socket, void *address, int *address_length);
+int ios_web_posix_socket_accept(int socket, void *address, int *address_length);
+int posix_socket_accept(int socket, void *address, int *address_length) { return halo_browser_rooms ? ios_web_posix_socket_accept(socket, address, address_length) : ios_native_posix_socket_accept(socket, address, address_length); }
+int ios_native_posix_socket_send(int socket, const void *buffer, int length, int flags);
+int ios_web_posix_socket_send(int socket, const void *buffer, int length, int flags);
+int posix_socket_send(int socket, const void *buffer, int length, int flags) { return halo_browser_rooms ? ios_web_posix_socket_send(socket, buffer, length, flags) : ios_native_posix_socket_send(socket, buffer, length, flags); }
+int ios_native_posix_socket_sendto(int socket, const void *buffer, int length, int flags, const void *address, int address_length);
+int ios_web_posix_socket_sendto(int socket, const void *buffer, int length, int flags, const void *address, int address_length);
+int posix_socket_sendto(int socket, const void *buffer, int length, int flags, const void *address, int address_length) { return halo_browser_rooms ? ios_web_posix_socket_sendto(socket, buffer, length, flags, address, address_length) : ios_native_posix_socket_sendto(socket, buffer, length, flags, address, address_length); }
+int ios_native_posix_socket_recv(int socket, void *buffer, int length, int flags);
+int ios_web_posix_socket_recv(int socket, void *buffer, int length, int flags);
+int posix_socket_recv(int socket, void *buffer, int length, int flags) { return halo_browser_rooms ? ios_web_posix_socket_recv(socket, buffer, length, flags) : ios_native_posix_socket_recv(socket, buffer, length, flags); }
+int ios_native_posix_socket_recvfrom(int socket, void *buffer, int length, int flags, void *address, int *address_length);
+int ios_web_posix_socket_recvfrom(int socket, void *buffer, int length, int flags, void *address, int *address_length);
+int posix_socket_recvfrom(int socket, void *buffer, int length, int flags, void *address, int *address_length) { return halo_browser_rooms ? ios_web_posix_socket_recvfrom(socket, buffer, length, flags, address, address_length) : ios_native_posix_socket_recvfrom(socket, buffer, length, flags, address, address_length); }
+int ios_native_posix_socket_shutdown(int socket, int how);
+int ios_web_posix_socket_shutdown(int socket, int how);
+int posix_socket_shutdown(int socket, int how) { return halo_browser_rooms ? ios_web_posix_socket_shutdown(socket, how) : ios_native_posix_socket_shutdown(socket, how); }
+int ios_native_posix_socket_set_nonblocking(int socket, int nonblocking);
+int ios_web_posix_socket_set_nonblocking(int socket, int nonblocking);
+int posix_socket_set_nonblocking(int socket, int nonblocking) { return halo_browser_rooms ? ios_web_posix_socket_set_nonblocking(socket, nonblocking) : ios_native_posix_socket_set_nonblocking(socket, nonblocking); }
+int ios_native_posix_socket_bytes_available(int socket, posix_ulong *count);
+int ios_web_posix_socket_bytes_available(int socket, posix_ulong *count);
+int posix_socket_bytes_available(int socket, posix_ulong *count) { return halo_browser_rooms ? ios_web_posix_socket_bytes_available(socket, count) : ios_native_posix_socket_bytes_available(socket, count); }
+int ios_native_posix_socket_set_nodelay(int socket);
+int ios_web_posix_socket_set_nodelay(int socket);
+int posix_socket_set_nodelay(int socket) { return halo_browser_rooms ? ios_web_posix_socket_set_nodelay(socket) : ios_native_posix_socket_set_nodelay(socket); }
+int ios_native_posix_socket_setsockopt(int socket, int level, int name, const void *value, int length);
+int ios_web_posix_socket_setsockopt(int socket, int level, int name, const void *value, int length);
+int posix_socket_setsockopt(int socket, int level, int name, const void *value, int length) { return halo_browser_rooms ? ios_web_posix_socket_setsockopt(socket, level, name, value, length) : ios_native_posix_socket_setsockopt(socket, level, name, value, length); }
+int ios_native_posix_socket_getsockopt(int socket, int level, int name, void *value, int *length);
+int ios_web_posix_socket_getsockopt(int socket, int level, int name, void *value, int *length);
+int posix_socket_getsockopt(int socket, int level, int name, void *value, int *length) { return halo_browser_rooms ? ios_web_posix_socket_getsockopt(socket, level, name, value, length) : ios_native_posix_socket_getsockopt(socket, level, name, value, length); }
+int ios_native_posix_socket_getsockname(int socket, void *address, int *address_length);
+int ios_web_posix_socket_getsockname(int socket, void *address, int *address_length);
+int posix_socket_getsockname(int socket, void *address, int *address_length) { return halo_browser_rooms ? ios_web_posix_socket_getsockname(socket, address, address_length) : ios_native_posix_socket_getsockname(socket, address, address_length); }
+int ios_native_posix_socket_getpeername(int socket, void *address, int *address_length);
+int ios_web_posix_socket_getpeername(int socket, void *address, int *address_length);
+int posix_socket_getpeername(int socket, void *address, int *address_length) { return halo_browser_rooms ? ios_web_posix_socket_getpeername(socket, address, address_length) : ios_native_posix_socket_getpeername(socket, address, address_length); }
+int ios_native_posix_socket_select(int *read, int *read_count, int *write, int *write_count, int *error, int *error_count, posix_long timeout_seconds, posix_long timeout_microseconds, int infinite);
+int ios_web_posix_socket_select(int *read, int *read_count, int *write, int *write_count, int *error, int *error_count, posix_long timeout_seconds, posix_long timeout_microseconds, int infinite);
+int posix_socket_select(int *read, int *read_count, int *write, int *write_count, int *error, int *error_count, posix_long timeout_seconds, posix_long timeout_microseconds, int infinite) { return halo_browser_rooms ? ios_web_posix_socket_select(read, read_count, write, write_count, error, error_count, timeout_seconds, timeout_microseconds, infinite) : ios_native_posix_socket_select(read, read_count, write, write_count, error, error_count, timeout_seconds, timeout_microseconds, infinite); }
+posix_ulong ios_native_posix_local_ipv4_address(void);
+posix_ulong ios_web_posix_local_ipv4_address(void);
+posix_ulong posix_local_ipv4_address(void) { return halo_browser_rooms ? ios_web_posix_local_ipv4_address() : ios_native_posix_local_ipv4_address(); }
+posix_ulong ios_native_posix_resolve_ipv4(const char *host);
+posix_ulong ios_web_posix_resolve_ipv4(const char *host);
+posix_ulong posix_resolve_ipv4(const char *host) { return halo_browser_rooms ? ios_web_posix_resolve_ipv4(host) : ios_native_posix_resolve_ipv4(host); }
diff --git a/port/ios/network/host_room.m b/port/ios/network/host_room.m
new file mode 100644
index 0000000000..f7ba3903b8
--- /dev/null
+++ b/port/ios/network/host_room.m
@@ -0,0 +1,342 @@
+/* The game executes natively. WebKit hosts only the bundled PR #12 WebRTC
+ transport, with copied bounded packet queues and no downloaded game code. */
+#if HALO_MACOS
+#import
+#else
+#import
+#endif
+#import
+#import
+#include
+#include "room_bridge.h"
+#include "../host/ios_host.h"
+
+static WKWebView *transport;
+#if HALO_MACOS
+static NSWindow *room_window;
+static NSTextField *status_label;
+@interface NSTextField (HaloText)
+@property(nonatomic,copy) NSString *text;
+@end
+@implementation NSTextField (HaloText)
+-(NSString *)text{return self.stringValue;}
+-(void)setText:(NSString *)text{self.stringValue=text?:@"";}
+@end
+#else
+static UIWindow *room_window;
+static UILabel *status_label;
+#endif
+static NSMutableArray *reports;
+static BOOL in_flight, selected, manual;
+static uint32_t input_ack;
+static NSUInteger session_generation;
+static CFTimeInterval last_tick;
+static BOOL test_navigation_consumed;
+void host_ios_room_test_navigation_consumed(void) {test_navigation_consumed=YES;}
+
+#if HALO_MACOS
+@interface HaloRoomController : NSObject
+@property(nonatomic,strong) NSTextField *code,*status;
+@property(nonatomic) BOOL chosen,cancelled;
+@end
+@implementation HaloRoomController
+-(instancetype)init {if(!(self=[super init]))return nil;
+ self.code=[[NSTextField alloc]initWithFrame:NSMakeRect(20,115,400,28)];self.code.placeholderString=@"Room code";self.code.stringValue=[NSUserDefaults.standardUserDefaults stringForKey:@"haloRoom"]?:@"FQLX01";
+ self.status=[NSTextField wrappingLabelWithString:@"Enter the same room code as your friends."];self.status.frame=NSMakeRect(20,60,400,45);
+ return self;}
+-(void)joinRoom {if(!self.code.stringValue.length)return;self.chosen=YES;self.code.enabled=NO;self.status.stringValue=@"Connecting…";}
+-(void)cancelRoom {self.cancelled=YES;self.chosen=YES;}
+@end
+static void roomPump(void) {
+ NSEvent *event=[NSApp nextEventMatchingMask:NSEventMaskAny untilDate:[NSDate dateWithTimeIntervalSinceNow:.008] inMode:NSDefaultRunLoopMode dequeue:YES];if(event)[NSApp sendEvent:event];
+ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.002]];
+}
+#else
+@interface HaloRoomController : UIViewController
+@property(nonatomic,strong) UITextField *code;
+@property(nonatomic,strong) UILabel *status;
+@property(nonatomic,strong) UIScrollView *scroll;
+@property(nonatomic,strong) UIButton *join;
+@property(nonatomic) BOOL chosen, cancelled;
+@end
+@implementation HaloRoomController
+- (void)viewDidLoad {
+ [super viewDidLoad];self.view.backgroundColor=UIColor.systemBackgroundColor;
+ UILabel *title=[UILabel new];title.text=@"Online multiplayer";title.font=[UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];
+ UILabel *hint=[UILabel new];hint.text=@"Enter the same room code as your friends.";hint.numberOfLines=0;
+ self.code=[UITextField new];self.code.borderStyle=UITextBorderStyleRoundedRect;self.code.placeholder=@"Room code";
+ self.code.text=[NSUserDefaults.standardUserDefaults stringForKey:@"haloRoom"] ?: @"FQLX01";
+ self.code.autocapitalizationType=UITextAutocapitalizationTypeAllCharacters;
+ self.code.autocorrectionType=UITextAutocorrectionTypeNo;self.code.returnKeyType=UIReturnKeyJoin;self.code.delegate=self;
+ self.code.accessibilityIdentifier=@"halo.room.code";
+ self.status=[UILabel new];self.status.numberOfLines=0;self.status.font=[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
+ UIStackView *fields=[[UIStackView alloc]initWithArrangedSubviews:@[title,hint,self.code,self.status]];
+ fields.axis=UILayoutConstraintAxisVertical;fields.spacing=8;fields.translatesAutoresizingMaskIntoConstraints=NO;
+ self.scroll=[UIScrollView new];self.scroll.translatesAutoresizingMaskIntoConstraints=NO;self.scroll.keyboardDismissMode=UIScrollViewKeyboardDismissModeInteractive;
+ [self.view addSubview:self.scroll];[self.scroll addSubview:fields];
+ UIButton *cancel=[UIButton buttonWithType:UIButtonTypeSystem];[cancel setTitle:@"Cancel" forState:UIControlStateNormal];
+ [cancel addTarget:self action:@selector(cancelRoom) forControlEvents:UIControlEventTouchUpInside];
+ self.join=[UIButton buttonWithType:UIButtonTypeSystem];[self.join setTitle:@"Join room" forState:UIControlStateNormal];
+ [self.join addTarget:self action:@selector(joinRoom) forControlEvents:UIControlEventTouchUpInside];
+ UIStackView *actions=[[UIStackView alloc]initWithArrangedSubviews:@[cancel,self.join]];actions.distribution=UIStackViewDistributionFillEqually;actions.spacing=16;actions.translatesAutoresizingMaskIntoConstraints=NO;
+ [self.view addSubview:actions];UILayoutGuide *safe=self.view.safeAreaLayoutGuide;
+ [NSLayoutConstraint activateConstraints:@[
+ [actions.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:16],
+ [actions.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-16],
+ [actions.bottomAnchor constraintEqualToAnchor:self.view.keyboardLayoutGuide.topAnchor constant:-8],
+ [actions.heightAnchor constraintEqualToConstant:44],
+ [self.scroll.topAnchor constraintEqualToAnchor:safe.topAnchor constant:8],
+ [self.scroll.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor constant:16],
+ [self.scroll.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor constant:-16],
+ [self.scroll.bottomAnchor constraintEqualToAnchor:actions.topAnchor constant:-8],
+ [fields.topAnchor constraintEqualToAnchor:self.scroll.contentLayoutGuide.topAnchor],
+ [fields.bottomAnchor constraintEqualToAnchor:self.scroll.contentLayoutGuide.bottomAnchor],
+ [fields.leadingAnchor constraintEqualToAnchor:self.scroll.contentLayoutGuide.leadingAnchor],
+ [fields.trailingAnchor constraintEqualToAnchor:self.scroll.contentLayoutGuide.trailingAnchor],
+ [fields.widthAnchor constraintEqualToAnchor:self.scroll.frameLayoutGuide.widthAnchor],
+ [self.code.heightAnchor constraintEqualToConstant:44]]];
+}
+- (void)viewDidLayoutSubviews {
+ [super viewDidLayoutSubviews];
+ if(self.code.isFirstResponder)[self.scroll scrollRectToVisible:[self.code convertRect:self.code.bounds toView:self.scroll] animated:NO];
+}
+- (void)joinRoom {
+ NSString *text=[self.code.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ if(!text.length){self.status.text=@"Enter a room code.";return;}
+ self.code.text=text;self.chosen=YES;self.join.enabled=NO;self.code.enabled=NO;
+ [self.view endEditing:YES];self.status.text=@"Connecting…";
+}
+- (void)cancelRoom {self.cancelled=YES;self.chosen=YES;[self.view endEditing:YES];}
+- (BOOL)textFieldShouldReturn:(UITextField *)field {(void)field;[self joinRoom];return NO;}
+@end
+
+#endif
+
+@interface HaloRoomNavigation : NSObject
+@end
+@implementation HaloRoomNavigation
+- (void)webView:(WKWebView *)view decidePolicyForNavigationAction:(WKNavigationAction *)action decisionHandler:(void (^)(WKNavigationActionPolicy))handler {
+ (void)view;
+ NSString *host = action.request.URL.host;
+ handler(([host isEqualToString:@"halo-native.invalid"] || [action.request.URL.absoluteString isEqualToString:@"about:blank"])
+ ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel);
+}
+- (void)webViewWebContentProcessDidTerminate:(WKWebView *)view {
+ if(view!=transport)return;
+ host_logf(HOST_LOG_ERROR, "Room transport stopped; restart the app to reconnect.");
+ const uint32_t command[4] = {2, 1, 0, 0}; ios_room_queue_command(command);
+ status_label.text = @"Network process stopped. Restart the app to reconnect.";
+ transport = nil;
+}
+@end
+static HaloRoomNavigation *navigation;
+
+static void network_error(NSString *message) {
+ status_label.text = message;
+ host_logf(HOST_LOG_ERROR, "Browser room: %s", message.UTF8String);
+ if (selected) { const uint32_t c[4] = {2,1,0,0}; ios_room_queue_command(c); }
+}
+
+void host_ios_room_report(const char *json) {
+ NSString *copy = [NSString stringWithUTF8String:json];
+ void (^append)(void) = ^{
+ NSDictionary *value = [NSJSONSerialization JSONObjectWithData:[copy dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
+ if (!value) return;
+ NSString *phase=value[@"phase"], *message=value[@"message"];
+ if(![phase isEqualToString:@"checkpoint"])host_logf(HOST_LOG_INFO,"Multiplayer %s: %s",phase.UTF8String,message.UTF8String);
+ int host_ios_browser_mode(void);
+ if(!host_ios_browser_mode()) {void host_ios_browser_status(const char *,const char *);host_ios_browser_status(phase.UTF8String,message.UTF8String);}
+ else if([phase isEqualToString:@"error"]){void host_ios_browser_error(const char *);host_ios_browser_error(message.UTF8String);}
+ if (!transport) return;
+ if (reports.count >= 128) {
+ network_error(@"Room status queue overflow; restart to reconnect."); transport = nil; return;
+ }
+ [reports addObject:value];
+ };
+ if (NSThread.isMainThread) append(); else dispatch_async(dispatch_get_main_queue(), append);
+}
+
+void host_ios_room_tick(void) {
+ // Opt-in device test navigation, never enabled in normal launches.
+ static double test_started;
+ if(!test_started)test_started=CACurrentMediaTime();
+ if(!test_navigation_consumed && [NSProcessInfo.processInfo.environment[@"HALO_NATIVE_TEST_BROWSER"] boolValue] && CACurrentMediaTime()-test_started>20) {
+ test_navigation_consumed=YES;const uint32_t command[4]={7,0,0,0};ios_room_queue_command(command);
+ }
+ if (!transport || in_flight || !NSThread.isMainThread || CACurrentMediaTime() - last_tick < .008) return;
+ last_tick = CACurrentMediaTime();
+ NSMutableData *out = [NSMutableData dataWithLength:256 * 1024];
+ uint32_t end;
+ size_t length = ios_room_read_output(out.mutableBytes, out.length, &end);
+ out.length = length;
+ NSUInteger report_count = reports.count;
+ NSDictionary *request = @{ @"out": [out base64EncodedStringWithOptions:0], @"ack": @(input_ack), @"reports": [reports copy] };
+ NSData *json = [NSJSONSerialization dataWithJSONObject:request options:0 error:nil];
+ NSString *script = [NSString stringWithFormat:@"HaloNative.exchange(%@)", [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding]];
+ in_flight = YES;
+ NSUInteger generation=session_generation;
+ [transport evaluateJavaScript:script completionHandler:^(id result, NSError *error) {
+ if(generation!=session_generation)return;
+ in_flight = NO;
+ if (error || ![result isKindOfClass:NSDictionary.class]) {
+ // During initial document loading HaloNative does not exist yet.
+ if (!selected && error) return;
+ network_error(error.localizedDescription ?: @"Invalid transport response"); transport = nil; return;
+ }
+ NSDictionary *value = result;
+ NSUInteger sent = [value[@"sent"] unsignedIntegerValue];
+ if (sent > length || !ios_room_ack_output(end - (uint32_t)length + (uint32_t)sent)) {
+ network_error(@"Invalid network acknowledgement"); transport = nil; return;
+ }
+ if (report_count) [reports removeObjectsInRange:NSMakeRange(0, report_count)];
+ NSDictionary *incoming = value[@"input"];
+ NSString *encoded = incoming[@"data"];
+ if (encoded.length) {
+ NSData *data = [[NSData alloc] initWithBase64EncodedString:encoded options:0];
+ if (data && data.length <= 256 * 1024 && ios_room_write_input(data.bytes, data.length))
+ input_ack = [incoming[@"id"] unsignedIntValue];
+ }
+ for (NSArray *raw in value[@"commands"]) {
+ if (raw.count != 4) continue;
+ uint32_t c[4]; for (int i=0;i<4;i++) c[i]=[raw[i] unsignedIntValue];
+ if (!ios_room_queue_command(c)) { network_error(@"Room command queue full"); transport=nil; return; }
+ }
+ __atomic_store_n(&web_shared_state()->net_local_address, [value[@"address"] unsignedIntValue], __ATOMIC_RELEASE);
+ if ([value[@"error"] isKindOfClass:NSString.class]) network_error(value[@"error"]);
+ else if ([value[@"status"] isKindOfClass:NSString.class]) status_label.text=value[@"status"];
+ NSDictionary *pings = value[@"pings"];
+ NSArray *rows = pings[@"rows"];
+ int32_t table[WEB_PING_PEERS][2];
+ NSUInteger count = MIN(rows.count, WEB_PING_PEERS);
+ for (NSUInteger i = 0; i < count; i++) {
+ table[i][0] = [rows[i][0] intValue]; table[i][1] = [rows[i][1] intValue];
+ }
+ ios_room_update_pings([pings[@"host"] unsignedIntValue], [pings[@"epoch"] unsignedIntValue],
+ [pings[@"updated"] unsignedIntValue], table, count);
+ NSDictionary *selection=value[@"selected"];
+ if (!selected && !manual && [selection isKindOfClass:NSDictionary.class]) {
+ NSString *role=selection[@"role"];
+ if (![role isEqualToString:@"host"] && ![role isEqualToString:@"join"]) return;
+ uint32_t ip=[selection[@"hostAddress"] unsignedIntValue];
+ const uint32_t start[4]={6,[role isEqualToString:@"host"]?1U:2U,ip,[selection[@"epoch"] unsignedIntValue]};
+ if(!ios_room_queue_command(start)){network_error(@"Room command queue full");return;}
+ selected=YES;
+ }
+ }];
+}
+
+void host_ios_room_prepare(void) {
+#if TARGET_OS_SIMULATOR
+ if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_ROOM"] length] ||
+ [NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_ROOM_UI"] isEqualToString:@"1"])host_ios_room_open();
+#endif
+}
+
+void host_ios_room_open(void) {
+ host_ios_room_test_navigation_consumed();
+#if HALO_MACOS
+ NSWindow *previous=NSApp.keyWindow;
+#else
+ UIWindow *previous=nil;
+ UIWindowScene *scene=nil;
+ for (UIScene *candidate in UIApplication.sharedApplication.connectedScenes)
+ if ([candidate isKindOfClass:UIWindowScene.class]) {scene=(UIWindowScene*)candidate;break;}
+ if(!scene){host_logf(HOST_LOG_ERROR,"No window scene for room chooser");return;}
+ for(UIWindow *window in scene.windows)if(window.isKeyWindow)previous=window;
+#endif
+ ++session_generation;
+ [transport evaluateJavaScript:@"HaloNative.leave()" completionHandler:nil];
+ transport=nil;
+#if HALO_MACOS
+ [room_window orderOut:nil];
+#else
+ room_window.hidden=YES;
+#endif
+ in_flight=selected=manual=NO;input_ack=0;last_tick=0;
+ ios_room_reset();reports=[NSMutableArray new];host_ios_touch_reset();
+#if HALO_MACOS
+ room_window=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,440,180) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];room_window.title=@"Online multiplayer";
+ HaloRoomController *controller=[HaloRoomController new];status_label=controller.status;
+ [room_window.contentView addSubview:controller.code];[room_window.contentView addSubview:controller.status];
+ NSButton *join=[NSButton buttonWithTitle:@"Join room" target:controller action:@selector(joinRoom)];join.frame=NSMakeRect(320,15,100,32);[room_window.contentView addSubview:join];
+ NSButton *cancel=[NSButton buttonWithTitle:@"Cancel" target:controller action:@selector(cancelRoom)];cancel.frame=NSMakeRect(200,15,100,32);[room_window.contentView addSubview:cancel];
+ [room_window center];[room_window makeKeyAndOrderFront:nil];
+ while(!controller.chosen)roomPump();
+ if(controller.cancelled){[room_window orderOut:nil];[previous makeKeyAndOrderFront:nil];return;}
+#else
+ room_window=[[UIWindow alloc]initWithWindowScene:scene];
+ HaloRoomController *controller=[HaloRoomController new];room_window.rootViewController=controller;
+ room_window.windowLevel=UIWindowLevelNormal+1;[room_window makeKeyAndVisible];
+ [controller loadViewIfNeeded];status_label=controller.status;
+#if TARGET_OS_SIMULATOR
+ NSString *test_room=NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_ROOM"];
+ if(test_room.length){controller.code.text=test_room;[controller joinRoom];}
+ else if([NSProcessInfo.processInfo.environment[@"HALO_IOS_TEST_ROOM_UI"] isEqualToString:@"1"])[controller.code becomeFirstResponder];
+#endif
+ while(!controller.chosen)[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.01]];
+ if(controller.cancelled){room_window.hidden=YES;[previous makeKeyAndVisible];return;}
+#endif
+ NSString *code=controller.code.text;
+ [NSUserDefaults.standardUserDefaults setObject:code forKey:@"haloRoom"];
+ WKWebViewConfiguration *config=[WKWebViewConfiguration new];
+ // Isolated persistent origin retains the virtual room address across launches.
+ config.websiteDataStore=WKWebsiteDataStore.defaultDataStore;
+ transport=[[WKWebView alloc] initWithFrame:CGRectMake(0,0,1,1) configuration:config];
+ navigation=[HaloRoomNavigation new];transport.navigationDelegate=navigation;
+#if HALO_MACOS
+ [room_window.contentView addSubview:transport];
+#else
+ transport.userInteractionEnabled=NO;
+ [controller.view addSubview:transport];
+#endif
+ NSMutableString *html=[NSMutableString stringWithString:@""];
+ [transport loadHTMLString:html baseURL:[NSURL URLWithString:@"https://halo-native.invalid/"]];
+ // Optional TURN credentials stay in the user's Documents/browser-room.json.
+ NSMutableDictionary *options=[NSMutableDictionary dictionaryWithDictionary:@{@"room":code ?: @""}];
+ NSData *settings=[NSData dataWithContentsOfFile:@"browser-room.json"];
+ if(settings){id value=[NSJSONSerialization JSONObjectWithData:settings options:0 error:nil];if([value isKindOfClass:NSDictionary.class] && [value[@"turn"] isKindOfClass:NSDictionary.class])options[@"turn"]=value[@"turn"];}
+ NSString *json=[[NSString alloc]initWithData:[NSJSONSerialization dataWithJSONObject:options options:0 error:nil] encoding:NSUTF8StringEncoding];
+ NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:90];
+ __block BOOL joining=NO;
+ while (!selected && !controller.cancelled && deadline.timeIntervalSinceNow>0) {
+ if(!transport.loading && !joining){
+ joining=YES;
+ [transport evaluateJavaScript:[NSString stringWithFormat:@"HaloNative.join(%@); true",json] completionHandler:^(id value,NSError*error){(void)value;if(error)joining=NO;}];
+ }
+ host_ios_room_tick();
+#if HALO_MACOS
+ roomPump();
+#else
+ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.01]];
+#endif
+ }
+ if (!selected || controller.cancelled) {
+ ++session_generation;
+ ios_room_reset();selected=NO;in_flight=NO;
+ [transport evaluateJavaScript:@"HaloNative.leave()" completionHandler:nil];
+ transport=nil;manual=YES;
+ host_logf(HOST_LOG_ERROR,"Room connection cancelled or timed out; returning to menus.");
+#if HALO_MACOS
+ [room_window orderOut:nil];
+#else
+ room_window.hidden=YES;
+#endif
+ }
+ // Retain the WebKit view in the foreground scene beneath the SDL window.
+#if HALO_MACOS
+ [room_window orderOut:nil];
+ [previous makeKeyAndOrderFront:nil];
+#else
+ room_window.windowLevel=UIWindowLevelNormal-1;
+ status_label.hidden=YES;
+ [previous makeKeyAndVisible];
+#endif
+ host_ios_touch_reset();
+}
diff --git a/port/ios/network/host_web_net.c b/port/ios/network/host_web_net.c
new file mode 100644
index 0000000000..7a9f2719f6
--- /dev/null
+++ b/port/ios/network/host_web_net.c
@@ -0,0 +1,24 @@
+/* Keep the PR #12 virtual sockets available as a separate transport. */
+#define posix_socket_last_error ios_web_posix_socket_last_error
+#define posix_socket ios_web_posix_socket
+#define posix_socket_close ios_web_posix_socket_close
+#define posix_socket_bind ios_web_posix_socket_bind
+#define posix_socket_connect ios_web_posix_socket_connect
+#define posix_socket_listen ios_web_posix_socket_listen
+#define posix_socket_accept ios_web_posix_socket_accept
+#define posix_socket_send ios_web_posix_socket_send
+#define posix_socket_sendto ios_web_posix_socket_sendto
+#define posix_socket_recv ios_web_posix_socket_recv
+#define posix_socket_recvfrom ios_web_posix_socket_recvfrom
+#define posix_socket_shutdown ios_web_posix_socket_shutdown
+#define posix_socket_set_nonblocking ios_web_posix_socket_set_nonblocking
+#define posix_socket_bytes_available ios_web_posix_socket_bytes_available
+#define posix_socket_set_nodelay ios_web_posix_socket_set_nodelay
+#define posix_socket_setsockopt ios_web_posix_socket_setsockopt
+#define posix_socket_getsockopt ios_web_posix_socket_getsockopt
+#define posix_socket_getsockname ios_web_posix_socket_getsockname
+#define posix_socket_getpeername ios_web_posix_socket_getpeername
+#define posix_socket_select ios_web_posix_socket_select
+#define posix_local_ipv4_address ios_web_posix_local_ipv4_address
+#define posix_resolve_ipv4 ios_web_posix_resolve_ipv4
+#include "../../web/src/web_net.c"
diff --git a/port/ios/network/network_mode.h b/port/ios/network/network_mode.h
new file mode 100644
index 0000000000..f7fcab537d
--- /dev/null
+++ b/port/ios/network/network_mode.h
@@ -0,0 +1,3 @@
+/* Fixed for a process lifetime: existing sockets never change transports. */
+#pragma once
+extern int halo_browser_rooms;
diff --git a/port/ios/network/room.js b/port/ios/network/room.js
new file mode 100644
index 0000000000..80d2b7ba8c
--- /dev/null
+++ b/port/ios/network/room.js
@@ -0,0 +1,110 @@
+/* Adapt the unmodified PR #12 HaloNet to copied native packet batches.
+ Only this JS process accesses these arrays: SharedArrayBuffer is unnecessary. */
+'use strict';
+const HaloNative = (() => {
+ const OUT = 1024 * 1024, IN = 2 * OUT, LIMIT = 256 * 1024;
+ const PING_PEERS = 128, META = 40 + PING_PEERS * 8;
+ const offsets = { netLocalAddress: 0, netOutWrite: 4, netOutRead: 8,
+ netInWrite: 12, netInRead: 16, pingSequence: 20, pingHost: 24,
+ pingEpoch: 28, pingUpdated: 32, pingCount: 36, pingPeers: 40, pingPeerCount: PING_PEERS,
+ netOut: META, netOutBytes: OUT,
+ netIn: META + OUT, netInBytes: IN };
+ const buffer = new ArrayBuffer(META + OUT + IN);
+ const bytes = new Uint8Array(buffer), words = new Int32Array(buffer);
+ const commands = [];
+ let pending = null, inputId = 0, selected = null, error = null, status = 'Ready';
+ function command(...values) {
+ if (commands.length >= 64) throw new Error('Native room control queue full');
+ while (values.length < 4) values.push(0);
+ commands.push(values.map(value => value >>> 0));
+ }
+ function encode(array) {
+ let text = '';
+ for (let i = 0; i < array.length; i += 8192)
+ text += String.fromCharCode(...array.subarray(i, i + 8192));
+ return btoa(text);
+ }
+ function decode(text) {
+ if (typeof text !== 'string' || text.length > Math.ceil(LIMIT / 3) * 4)
+ throw new Error('Invalid native packet batch');
+ return Uint8Array.from(atob(text), c => c.charCodeAt(0));
+ }
+ function frames(data) {
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ for (let i = 0; i < data.length;) {
+ if (data.length - i < 24) throw new Error('Truncated packet header');
+ const size = view.getUint32(i, true), kind = view.getUint32(i + 4, true);
+ if (size < 24 || size % 4 || size > data.length - i ||
+ view.getUint32(i + 20, true) > size - 24 || kind < 1 || kind > 5)
+ throw new Error('Invalid packet framing');
+ i += size;
+ }
+ }
+ function append(data) {
+ frames(data);
+ const write = words[1] >>> 0, read = words[2] >>> 0;
+ if (((write - read) >>> 0) > OUT || data.length > OUT - ((write - read) >>> 0)) return 0;
+ const start = write & (OUT - 1), first = Math.min(data.length, OUT - start);
+ bytes.set(data.subarray(0, first), offsets.netOut + start);
+ bytes.set(data.subarray(first), offsets.netOut);
+ words[1] = (write + data.length) | 0;
+ return data.length;
+ }
+ function incoming(ack) {
+ if (pending && ack === pending.id) {
+ words[4] = pending.end | 0;
+ pending = null;
+ }
+ if (pending) return pending;
+ let read = words[4] >>> 0;
+ const write = words[3] >>> 0, result = [];
+ while (((write - read) >>> 0) >= 24) {
+ const head = new Uint8Array(24);
+ for (let i = 0; i < 24; i++) head[i] = bytes[offsets.netIn + ((read + i) & (IN - 1))];
+ const size = new DataView(head.buffer).getUint32(0, true);
+ if (size < 24 || size % 4 || size > ((write - read) >>> 0)) throw new Error('Corrupt receive ring');
+ if (result.length + size > LIMIT) break;
+ for (let i = 0; i < size; i++) result.push(bytes[offsets.netIn + ((read + i) & (IN - 1))]);
+ read = (read + size) >>> 0;
+ }
+ if (!result.length) return {id: 0, data: ''};
+ pending = {id: ++inputId, data: encode(Uint8Array.from(result)), end: read};
+ return pending;
+ }
+ function report(value) {
+ if (value.phase === 'checkpoint') return HaloNet.quickPlayCheckpoint(value);
+ status = value.message || value.phase;
+ if (value.phase === 'disconnected' && HaloNet.quickPlayLost()) return;
+ HaloNet.quickPlayPhase(value.phase);
+ }
+ HaloNet.attach({memory: {buffer}, base: 0, offsets});
+ return {
+ async join(options) {
+ try {
+ await HaloNet.join(options.room, options);
+ selected = await HaloNet.quickPlay({
+ onStatus(value) {
+ status = value.message || value.phase || 'Connecting';
+ if (typeof value.hold === 'boolean') command(2, value.hold ? 1 : 0);
+ },
+ onReconnect(value) { command(4, value.hostAddress, value.epoch); },
+ onFailover(value) { command(3, value.role === 'host' ? 1 : 2, value.hostAddress, value.epoch); }
+ });
+ command(1, HaloNet.address, selected.epoch);
+ HaloNet.quickPlayStarted();
+ } catch (failure) { error = String(failure); }
+ },
+ exchange(value) {
+ const sent = append(decode(value.out || ''));
+ for (const item of value.reports || []) report(item);
+ const input = incoming(value.ack);
+ return {sent, input, commands: commands.splice(0), selected, error, status,
+ address: HaloNet.address, pings: {host: words[6] >>> 0, epoch: words[7] >>> 0, updated: words[8] >>> 0,
+ rows: Array.from({length: Math.max(0, Math.min(PING_PEERS, words[9]))},
+ (_, i) => [words[10 + i * 2] >>> 0, words[11 + i * 2]])}};
+ },
+ async leave() { command(5); await HaloNet.leave(); selected = null; },
+ // Used by the protocol fixture, not exported across the native bridge.
+ memory: {buffer}, offsets
+ };
+})();
diff --git a/port/ios/network/room_bridge.c b/port/ios/network/room_bridge.c
new file mode 100644
index 0000000000..b1edf2924b
--- /dev/null
+++ b/port/ios/network/room_bridge.c
@@ -0,0 +1,95 @@
+/* Complete PR #12 packets, bounded queues, no cross-process shared pointers. */
+#include "room_bridge.h"
+#include
+#include
+static struct web_shared_state shared;
+static pthread_mutex_t command_lock = PTHREAD_MUTEX_INITIALIZER;
+static uint32_t commands[64][4], command_read, command_write;
+void ios_room_reset(void) {
+ pthread_mutex_lock(&command_lock);
+ memset(&shared,0,sizeof(shared));command_read=command_write=0;
+ pthread_mutex_unlock(&command_lock);
+}
+struct web_shared_state *web_shared_state(void) { return &shared; }
+static void ring_copy(void *out, const unsigned char *ring, size_t capacity,
+ uint32_t offset, size_t size) {
+ size_t start = offset & (capacity - 1), first = capacity - start;
+ if (first > size) first = size;
+ memcpy(out, ring + start, first);
+ memcpy((unsigned char *)out + first, ring, size - first);
+}
+size_t ios_room_read_output(void *bytes, size_t capacity, uint32_t *end) {
+ uint32_t read = __atomic_load_n(&shared.net_out_read, __ATOMIC_ACQUIRE);
+ uint32_t write = __atomic_load_n(&shared.net_out_write, __ATOMIC_ACQUIRE);
+ size_t copied = 0;
+ *end = read;
+ if (write - read > WEB_NET_OUT_BYTES) return 0;
+ while (write - read >= sizeof(struct web_packet_header)) {
+ struct web_packet_header h;
+ ring_copy(&h, shared.net_out, WEB_NET_OUT_BYTES, read, sizeof(h));
+ if (h.size < sizeof(h) || h.size % 4 || h.size > write - read || h.size > capacity - copied) break;
+ ring_copy((unsigned char *)bytes + copied, shared.net_out, WEB_NET_OUT_BYTES, read, h.size);
+ copied += h.size; read += h.size;
+ }
+ *end = read; return copied;
+}
+int ios_room_ack_output(uint32_t end) {
+ uint32_t read = __atomic_load_n(&shared.net_out_read, __ATOMIC_ACQUIRE);
+ uint32_t write = __atomic_load_n(&shared.net_out_write, __ATOMIC_ACQUIRE);
+ if (end - read > write - read) return 0;
+ __atomic_store_n(&shared.net_out_read, end, __ATOMIC_RELEASE); return 1;
+}
+int ios_room_write_input(const void *bytes, size_t size) {
+ uint32_t read = __atomic_load_n(&shared.net_in_read, __ATOMIC_ACQUIRE);
+ uint32_t write = __atomic_load_n(&shared.net_in_write, __ATOMIC_ACQUIRE);
+ const unsigned char *source = bytes; size_t cursor = 0;
+ if (!size) return 1;
+ if (!bytes) return 0;
+ if (write - read > WEB_NET_IN_BYTES || size > WEB_NET_IN_BYTES - (write - read)) return 0;
+ while (cursor < size) {
+ struct web_packet_header h;
+ if (size - cursor < sizeof(h)) return 0;
+ memcpy(&h, source + cursor, sizeof(h));
+ if (h.size < sizeof(h) || h.size % 4 || h.size > size - cursor ||
+ h.length > h.size - sizeof(h) || h.kind < WEB_PACKET_DATAGRAM || h.kind > WEB_PACKET_REFUSE) return 0;
+ cursor += h.size;
+ }
+ size_t start = write & (WEB_NET_IN_BYTES - 1), first = WEB_NET_IN_BYTES - start;
+ if (first > size) first = size;
+ memcpy(shared.net_in + start, source, first);
+ memcpy(shared.net_in, source + first, size - first);
+ __atomic_store_n(&shared.net_in_write, write + (uint32_t)size, __ATOMIC_RELEASE); return 1;
+}
+int ios_room_queue_command(const uint32_t command[4]) {
+ int accepted = 0; pthread_mutex_lock(&command_lock);
+ if (command_write - command_read < 64) {
+ memcpy(commands[command_write++ % 64], command, 16); accepted = 1;
+ }
+ pthread_mutex_unlock(&command_lock); return accepted;
+}
+int host_ios_room_poll(unsigned int *command) {
+ int available = 0; pthread_mutex_lock(&command_lock);
+ if (command_write != command_read) {
+ memcpy(command, commands[command_read++ % 64], 16); available = 1;
+ }
+ pthread_mutex_unlock(&command_lock); return available;
+}
+int web_net_host_ping(unsigned int address, unsigned int host, unsigned int epoch);
+int host_ios_room_ping(unsigned int address, unsigned int host, unsigned int epoch) {
+ return web_net_host_ping(address, host, epoch);
+}
+
+void ios_room_update_pings(uint32_t host, uint32_t epoch, uint32_t updated,
+ const int32_t rows[][2], size_t count) {
+ if (count > WEB_PING_PEERS) count = WEB_PING_PEERS;
+ __atomic_add_fetch(&shared.ping_sequence, 1, __ATOMIC_SEQ_CST);
+ __atomic_store_n(&shared.ping_host, (int32_t)host, __ATOMIC_SEQ_CST);
+ __atomic_store_n(&shared.ping_epoch, (int32_t)epoch, __ATOMIC_SEQ_CST);
+ __atomic_store_n(&shared.ping_updated, (int32_t)updated, __ATOMIC_SEQ_CST);
+ __atomic_store_n(&shared.ping_count, (int32_t)count, __ATOMIC_SEQ_CST);
+ for (size_t i = 0; i < count; i++) {
+ __atomic_store_n(&shared.ping_peers[i][0], rows[i][0], __ATOMIC_SEQ_CST);
+ __atomic_store_n(&shared.ping_peers[i][1], rows[i][1], __ATOMIC_SEQ_CST);
+ }
+ __atomic_add_fetch(&shared.ping_sequence, 1, __ATOMIC_SEQ_CST);
+}
diff --git a/port/ios/network/room_bridge.h b/port/ios/network/room_bridge.h
new file mode 100644
index 0000000000..db921588cf
--- /dev/null
+++ b/port/ios/network/room_bridge.h
@@ -0,0 +1,17 @@
+#ifndef HALO_IOS_ROOM_BRIDGE_H
+#define HALO_IOS_ROOM_BRIDGE_H
+#include
+#include
+#include "../../web/src/web_shared.h"
+/* One main-thread producer/consumer pairs with the socket backend. */
+size_t ios_room_read_output(void *bytes, size_t capacity, uint32_t *end);
+int ios_room_ack_output(uint32_t end);
+int ios_room_write_input(const void *bytes, size_t size);
+int ios_room_queue_command(const uint32_t command[4]);
+void ios_room_update_pings(uint32_t host, uint32_t epoch, uint32_t updated,
+ const int32_t rows[][2], size_t count);
+void host_ios_room_prepare(void);
+void host_ios_room_tick(void);
+void host_ios_room_open(void);
+void ios_room_reset(void);
+#endif
diff --git a/port/ios/tests/audio_probe.c b/port/ios/tests/audio_probe.c
new file mode 100644
index 0000000000..ae28418432
--- /dev/null
+++ b/port/ios/tests/audio_probe.c
@@ -0,0 +1,87 @@
+/* Exercise the real SDL stream lock and the host's cross-thread PCM handoff.
+ A stalled callback is a failure (alarm), not an indefinitely hanging test. */
+#include
+#include
+#include
+#include
+#ifndef HOST_SDL_SOURCE
+#define HOST_SDL_SOURCE "../host/host_sdl.c"
+#endif
+#include HOST_SDL_SOURCE
+
+static unsigned produced;
+static unsigned callbacks;
+
+void host_logf(int priority, const char *format, ...)
+{
+ (void)priority;
+ va_list args;
+ va_start(args, format);
+ vfprintf(stderr, format, args);
+ fputc('\n', stderr);
+ va_end(args);
+}
+
+void host_fatal(const char *format, ...)
+{
+ (void)format;
+ abort();
+}
+
+int host_native_thread_create(void *(*function)(void *), void *arg, size_t size)
+{
+ (void)size;
+ pthread_t thread;
+ int error = pthread_create(&thread, NULL, function, arg);
+ if (!error) pthread_detach(thread);
+ return error;
+}
+
+uint32_t host_call_guest(uint32_t callback, uint32_t userdata, uint32_t stream,
+ uint32_t additional, uint32_t total)
+{
+ (void)callback; (void)userdata; (void)total;
+ ++callbacks;
+ while (additional)
+ {
+ float buffer[256];
+ unsigned bytes = SDL_min(additional, sizeof(buffer));
+ for (unsigned i = 0; i < bytes / sizeof(float); ++i)
+ buffer[i] = (float)(produced++ % 251) / 251.0f;
+ assert(host_sdl_put_audio_stream_data(stream, buffer, (int)bytes));
+ /* The bridge must copy before the guest reuses its stack buffer. */
+ memset(buffer, 0, sizeof(buffer));
+ additional -= bytes;
+ }
+ assert(!host_sdl_put_audio_stream_data(stream, NULL, -1));
+ return 0;
+}
+
+int main(void)
+{
+ alarm(10);
+ SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
+ assert(SDL_Init(SDL_INIT_AUDIO));
+ SDL_AudioSpec spec = {SDL_AUDIO_F32, 2, 48000};
+ uint32_t handle = host_sdl_open_audio_stream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, 1, 0);
+ assert(handle);
+ SDL_AudioStream *stream = handle_get(handle, _handle_audio);
+ SDL_UnbindAudioStream(stream);
+ assert(SDL_SetAudioStreamFormat(stream, &spec, &spec));
+
+ unsigned consumed = 0;
+ for (int iteration = 0; iteration < 100; ++iteration)
+ {
+ /* The large request also exercises PCM buffer growth beyond 64 KB. */
+ float output[32768];
+ int bytes = iteration == 50 ? sizeof(output) : 4096;
+ assert(SDL_GetAudioStreamData(stream, output, bytes) == bytes);
+ for (int i = 0; i < bytes / (int)sizeof(float); ++i)
+ assert(output[i] == (float)(consumed++ % 251) / 251.0f);
+ }
+ assert(callbacks == 100);
+ assert(consumed == produced);
+ alarm(0);
+ printf("PASS: %u SDL callbacks, %u exact PCM samples; no cross-thread stream deadlock\n", callbacks, consumed);
+ return 0;
+}
diff --git a/port/ios/tests/display_probe.c b/port/ios/tests/display_probe.c
new file mode 100644
index 0000000000..453cb2931a
--- /dev/null
+++ b/port/ios/tests/display_probe.c
@@ -0,0 +1,28 @@
+#include "halo_display.h"
+#include
+#include
+
+static void check(long dw,long dh,long logical,long aspect,long height,long limit,long w,long h) {
+ struct halo_display_size size=halo_display_render_size(dw,dh,logical,aspect,height,limit);
+ assert(size.width==w && size.height==h);
+}
+int main(void) {
+ /* Native must use the full physical drawable, including when the Xbox
+ layout has rounded the display's ratio to an even column count. */
+ check(2752,2064,640,0,0,16384,2752,2064);
+ check(2868,1320,1042,0,0,16384,2868,1320);
+ check(2266,1488,730,0,0,16384,2266,1488);
+ /* Performance presets preserve shape; original 4:3 must letterbox. */
+ check(2752,2064,640,0,1080,16384,1440,1080);
+ check(2868,1320,1042,0,720,16384,1564,720);
+ check(2868,1320,640,640,0,16384,1760,1320);
+ check(2752,2064,854,854,480,16384,854,480);
+ /* Never supersample accidentally or exceed the GPU's texture limit. */
+ check(1280,720,854,0,1080,16384,1280,720);
+ check(7680,4320,854,0,0,4096,4096,2304);
+ check(2752,2064,640,0,-1,16384,2752,2064);
+ check(2752,2064,640,0,1,16384,320,240);
+ check(0,0,640,0,0,16384,640,480);
+ puts("PASS: native pixels, aspect preservation, resolution presets and GPU size limits");
+ return 0;
+}
diff --git a/port/ios/tests/guest_probe.c b/port/ios/tests/guest_probe.c
new file mode 100644
index 0000000000..adf7880fa5
--- /dev/null
+++ b/port/ios/tests/guest_probe.c
@@ -0,0 +1,24 @@
+/* Exercise translated pointer layouts, stack locals, indirect calls and atomics. */
+struct node { struct node *next; unsigned value; };
+_Static_assert(sizeof(struct node)==8,"guest pointers must remain 32-bit");
+static struct node nodes[3];
+static unsigned array[128];
+static volatile unsigned atomic_word;
+static unsigned __attribute__((noinline)) calculate(unsigned x) { return x*7+3; }
+static unsigned (*volatile callback)(unsigned)=calculate;
+static unsigned long long global_pointer(void) { return (unsigned long long)(unsigned)nodes; }
+unsigned guest_probe(void) {
+ unsigned local[32];unsigned error=0;
+ for(unsigned i=0;i<32;i++)local[i]=i*3;
+ for(unsigned i=0;i<128;i++)array[i]=i+1;
+ nodes[0]=(struct node){&nodes[1],19};nodes[1]=(struct node){&nodes[2],23};nodes[2]=(struct node){0,29};
+ unsigned sum=0;for(struct node *p=nodes;p;p=p->next)sum+=p->value;
+ if(sum!=71)error|=1;
+ if(callback(11)!=80)error|=2;
+ sum=0;for(unsigned i=0;i<32;i++)sum+=local[i];if(sum!=1488)error|=4;
+ sum=0;for(unsigned i=0;i<128;i++)sum+=array[i];if(sum!=8256)error|=8;
+ atomic_word=4;if(__sync_fetch_and_add(&atomic_word,9)!=4 || atomic_word!=13)error|=16;
+ return error;
+}
+__attribute__((section("__TEXT,__guest_header"),used))
+const struct {unsigned magic; unsigned (*entry)(void); unsigned long long (*pointer)(void);} __guest_header={0x4f4c4148,guest_probe,global_pointer};
diff --git a/port/ios/tests/memory_probe.c b/port/ios/tests/memory_probe.c
new file mode 100644
index 0000000000..073e0b19d5
--- /dev/null
+++ b/port/ios/tests/memory_probe.c
@@ -0,0 +1,42 @@
+/* Stress the exact render/streaming race on the host's native page size. */
+#include "ios_host.h"
+#include "guest_host.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+void host_logf(int level,const char *format,...) {
+ (void)level;va_list ap;va_start(ap,format);vfprintf(stderr,format,ap);fputc('\n',stderr);va_end(ap);
+}
+static unsigned finished, started;
+static void *writer(void *arg) {
+ volatile unsigned *p=host_pointer(0x80010000+(uintptr_t)arg*4);
+ __atomic_add_fetch(&started,1,__ATOMIC_RELEASE);
+ unsigned n=0;while(!__atomic_load_n(&finished,__ATOMIC_RELAXED))*p=++n;
+ return NULL;
+}
+int main(void) {
+ if(host_memory_initialize(0,0))return 1;
+ host_install_signal_handlers();host_memory_watch_initialize();
+ /* Reproduce the map inflater's writable 4 KB tail beside a read-only
+ buffer. The boundary Apple page must remain writable. */
+ if(host_guest_mprotect(0x80020000,0x6000,PROT_READ))return 4;
+ *(volatile unsigned *)host_pointer(0x80026014)=42;
+ pthread_t threads[4];
+ for(uintptr_t i=0;i<4;i++)pthread_create(&threads[i],NULL,writer,(void *)i);
+ while(__atomic_load_n(&started,__ATOMIC_ACQUIRE)<4)sched_yield();
+ for(int i=0;i<40000;i++){host_memory_watch_protect(0x80010000,4096);if(!(i%10))sched_yield();}
+ __atomic_store_n(&finished,1,__ATOMIC_RELAXED);
+ for(int i=0;i<4;i++)pthread_join(threads[i],NULL);
+ unsigned generation=host_memory_watch_generation(0x80010000,4096);
+ if(!generation)return 2;
+ host_memory_watch_prepare_write(0x80010000,4096);
+ unsigned *p=host_low_map(16384,PROT_READ|PROT_WRITE);*p=1234;
+ host_low_unmap(p,16384);p=host_low_map(16384,PROT_READ|PROT_WRITE);
+ if(*p)return 3;
+ printf("PASS: concurrent page tracking, generation %u; reused mmap zeroed\n",generation);
+ return 0;
+}
diff --git a/port/ios/tests/native_net_probe.c b/port/ios/tests/native_net_probe.c
new file mode 100644
index 0000000000..367422666a
--- /dev/null
+++ b/port/ios/tests/native_net_probe.c
@@ -0,0 +1,34 @@
+/* Real Darwin sockets through the game's 32-bit, Winsock-shaped boundary. */
+#include "posix.h"
+#include "network_mode.h"
+#include
+#include
+#include
+#include
+#include
+struct address {uint16_t family,port;uint32_t ip;unsigned char padding[8];};
+int main(void) {
+ assert(!halo_browser_rooms);
+ struct address addr={2,0,htonl(INADDR_LOOPBACK),{0}},peer;
+ int size=sizeof(addr),type=0,type_size=sizeof(type);
+ int listener=posix_socket(2,1,0);assert(listener>=0);
+ assert(!posix_socket_bind(listener,&addr,sizeof(addr)));
+ assert(!posix_socket_listen(listener,2));
+ assert(!posix_socket_getsockname(listener,&addr,&size));assert(addr.family==2 && addr.port);
+ assert(!posix_socket_getsockopt(listener,0xffff,0x1008,&type,&type_size) && type==1);
+ int client=posix_socket(2,1,0);assert(client>=0);
+ assert(!posix_socket_connect(client,&addr,sizeof(addr)));
+ size=sizeof(peer);int server=posix_socket_accept(listener,&peer,&size);assert(server>=0 && peer.family==2);
+ assert(!posix_socket_set_nodelay(client));assert(posix_socket_send(client,"hello",5,0)==5);
+ int read=server,count=1;assert(posix_socket_select(&read,&count,0,0,0,0,1,0,0)==1 && count==1);
+ char buffer[20];assert(posix_socket_recv(server,buffer,sizeof(buffer),0)==5 && !memcmp(buffer,"hello",5));
+ assert(!posix_socket_set_nonblocking(server,1));assert(posix_socket_recv(server,buffer,sizeof(buffer),0)==-1);assert(posix_socket_last_error()==10035);
+ assert(!posix_socket_close(server));assert(!posix_socket_close(client));assert(!posix_socket_close(listener));
+ int udp=posix_socket(2,2,0);assert(udp>=0);addr.port=0;assert(!posix_socket_bind(udp,&addr,sizeof(addr)));
+ size=sizeof(addr);assert(!posix_socket_getsockname(udp,&addr,&size));
+ assert(posix_socket_sendto(udp,"udp",3,0,&addr,sizeof(addr))==3);
+ read=udp;count=1;assert(posix_socket_select(&read,&count,0,0,0,0,1,0,0)==1);
+ size=sizeof(peer);assert(posix_socket_recvfrom(udp,buffer,sizeof(buffer),0,&peer,&size)==3 && peer.family==2 && peer.port==addr.port && !memcmp(buffer,"udp",3));
+ assert(!posix_socket_close(udp));
+ puts("PASS: native TCP/UDP, Winsock options/errors, Darwin sockaddr conversion and readiness");
+}
diff --git a/port/ios/tests/probe_runner.c b/port/ios/tests/probe_runner.c
new file mode 100644
index 0000000000..d4e658aa9b
--- /dev/null
+++ b/port/ios/tests/probe_runner.c
@@ -0,0 +1,30 @@
+/* Execute the compiler-translated probe from signed pages on an Apple ARM64 host. */
+#include
+#include
+#include
+#include
+#include
+#include "guest_image.h"
+extern unsigned long long host_guest_on_stack(uintptr_t,unsigned,uintptr_t,void *);
+int main(void) {
+ vm_address_t arena=0;
+ int result=vm_map(mach_task_self(),&arena,0x100000000ull,0xffffffffull,VM_FLAGS_ANYWHERE,
+ MACH_PORT_NULL,0,FALSE,VM_PROT_NONE,VM_PROT_ALL,VM_INHERIT_NONE);
+ if(result){fprintf(stderr,"reserve: %d\n",result);return 1;}
+ vm_address_t code=arena+0x88000000;vm_prot_t current,maximum;
+ result=vm_remap(mach_task_self(),&code,IOS_GUEST_CODE_SIZE,0,VM_FLAGS_FIXED|VM_FLAGS_OVERWRITE,
+ mach_task_self(),(vm_address_t)halo_guest_code,FALSE,¤t,&maximum,VM_INHERIT_COPY);
+ if(result){fprintf(stderr,"signed alias: %d\n",result);return 1;}
+ uintptr_t data=arena+IOS_GUEST_DATA_ADDRESS;
+ if(mprotect((void *)data,(IOS_GUEST_IMAGE_END-IOS_GUEST_DATA_ADDRESS+0x3fff)&~0x3fff,PROT_READ|PROT_WRITE))return 2;
+ memcpy((void *)data,halo_guest_data,IOS_GUEST_DATA_SIZE);
+ uintptr_t stack=arena+0x1000000;
+ if(mprotect((void *)stack,0x100000,PROT_READ|PROT_WRITE))return 3;
+ uint32_t entry=((const uint32_t *)code)[1];
+ result=host_guest_on_stack(arena+entry,0,arena,(void *)(stack+0x100000));
+ uint32_t pointer_entry=((const uint32_t *)code)[2];
+ uint64_t raw=host_guest_on_stack(arena+pointer_entry,0,arena,(void *)(stack+0x100000));
+ if(raw>=0x100000000ull || raw<0x88000000)result|=32;
+ printf("ILP32 guest probe: %s (failure bits %d, arena %lx)\n",result?"FAIL":"PASS",result,arena);
+ return result;
+}
diff --git a/port/ios/tests/room_bridge_probe.c b/port/ios/tests/room_bridge_probe.c
new file mode 100644
index 0000000000..709897fca5
--- /dev/null
+++ b/port/ios/tests/room_bridge_probe.c
@@ -0,0 +1,70 @@
+#include "../network/room_bridge.h"
+#include "../../linux/src/posix.h"
+#include
+#include
+#include
+#include
+#include "../../web/src/web_ping.h"
+
+int host_ios_room_poll(unsigned int *command);
+struct address { uint16_t family, port; uint32_t ip; unsigned char padding[8]; };
+int main(void) {
+ struct web_shared_state *s=web_shared_state();
+ /* Changing rooms must not replay packets, pings or commands from the old room. */
+ s->net_out_write=32;s->net_in_write=32;s->net_local_address=123;s->ping_count=1;
+ const uint32_t old_command[4]={3,1,123,9};unsigned int discarded[4];
+ assert(ios_room_queue_command(old_command));ios_room_reset();
+ assert(!host_ios_room_poll(discarded));
+ assert(!s->net_out_write && !s->net_in_write && !s->net_local_address && !s->ping_count);
+ struct address local={2,0x3412,0,{0}}, remote={2,0x7856,0x0302010a,{0}};
+ s->net_local_address=0x0402010a;
+ int socket=posix_socket(2,2,0);
+ assert(socket>=0 && posix_socket_bind(socket,&local,sizeof(local))==0);
+ assert(posix_socket_set_nonblocking(socket,1)==0);
+ /* Counter overflow and physical ring wrap are independent. */
+ s->net_out_read=s->net_out_write=(int32_t)0xfffffff0u;
+ assert(posix_socket_sendto(socket,"hello",5,0,&remote,sizeof(remote))==5);
+ unsigned char output[128];uint32_t end;
+ size_t size=ios_room_read_output(output,sizeof(output),&end);
+ assert(size==32 && end==16);
+ struct web_packet_header header;memcpy(&header,output,sizeof(header));
+ assert(header.kind==WEB_PACKET_DATAGRAM && header.length==5);
+ assert(!memcmp(output+sizeof(header),"hello",5));
+ assert(!ios_room_ack_output(end+4));
+ assert(ios_room_ack_output(end));
+ assert(ios_room_read_output(output,sizeof(output),&end)==0);
+ header.source_ip=remote.ip;header.destination_ip=(uint32_t)s->net_local_address;
+ header.source_port=remote.port;header.destination_port=local.port;
+ memcpy(output,&header,sizeof(header));
+ s->net_in_read=s->net_in_write=(int32_t)0xfffffff0u;
+ assert(ios_room_write_input(output,size));
+ char received[16];struct address from;int from_size=sizeof(from);
+ assert(posix_socket_recvfrom(socket,received,sizeof(received),0,&from,&from_size)==5);
+ assert(!memcmp(received,"hello",5) && from.ip==remote.ip);
+ assert((uint32_t)s->net_in_read==16);
+ /* A malformed batch must never publish even its valid first frame. */
+ uint32_t before=(uint32_t)s->net_in_write;
+ assert(!ios_room_write_input(output,23));
+ header.length=100;memcpy(output,&header,sizeof(header));
+ assert(!ios_room_write_input(output,size));
+ assert((uint32_t)s->net_in_write==before);
+ header.length=5;memcpy(output,&header,sizeof(header));
+ s->net_in_write=s->net_in_read+WEB_NET_IN_BYTES;
+ assert(!ios_room_write_input(output,size));
+ uint32_t command[4]={3,2,remote.ip,7},copy[4];
+ for(int i=0;i<64;i++) assert(ios_room_queue_command(command));
+ assert(!ios_room_queue_command(command));
+ for(int i=0;i<64;i++){assert(host_ios_room_poll(copy));assert(!memcmp(command,copy,16));}
+ assert(!host_ios_room_poll(copy));
+ struct timespec now;clock_gettime(CLOCK_REALTIME,&now);
+ uint32_t milliseconds=(uint32_t)((uint64_t)now.tv_sec*1000+now.tv_nsec/1000000);
+ int32_t pings[128][2];
+ for(int i=0;i<128;i++){pings[i][0]=0x0a010200+i;pings[i][1]=i;}
+ ios_room_update_pings(0x0a010204,7,milliseconds,pings,128);
+ assert(web_net_host_ping(0x7f02010a,0x0402010a,7)==127);
+ assert(web_net_host_ping(0x7f02010a,0x0402010a,8)==-1);
+ ios_room_update_pings(0x0a010204,7,milliseconds-60000,pings,128);
+ assert(web_net_host_ping(0x7f02010a,0x0402010a,7)==-1);
+ assert(posix_socket_close(socket)==0);
+ puts("PASS: native browser sockets, wraparound, framing, backpressure, control ordering");
+}
diff --git a/port/ios/tests/shader_cache_probe.m b/port/ios/tests/shader_cache_probe.m
new file mode 100644
index 0000000000..dc6a836b69
--- /dev/null
+++ b/port/ios/tests/shader_cache_probe.m
@@ -0,0 +1,42 @@
+#import
+#include
+#include
+#include
+#include
+#import "../host/host_shader_cache.m"
+void host_logf(int priority,const char *format,...) {(void)priority;(void)format;}
+int main(void) {@autoreleasepool {
+ cacheDirectory=[NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString];
+ assert([NSFileManager.defaultManager createDirectoryAtPath:cacheDirectory withIntermediateDirectories:YES attributes:nil error:nil]);
+ memoryCache=[NSCache new];pendingLock=[NSLock new];writer=dispatch_queue_create("cache-probe",DISPATCH_QUEUE_SERIAL);
+ const char key[]="key", bytes[]="compiled program";
+ storeBlob(key,sizeof(key),bytes,sizeof(bytes));dispatch_sync(writer,^{});
+ [memoryCache removeAllObjects];
+ assert(getBlob(key,sizeof(key),NULL,0)==sizeof(bytes));
+ char output[sizeof(bytes)];memset(output,0x5a,sizeof(output));
+ assert(getBlob(key,sizeof(key),output,2)==sizeof(bytes));assert(output[0]==0x5a);
+ assert(getBlob(key,sizeof(key),output,-1)==sizeof(bytes));assert(output[0]==0x5a);
+ assert(getBlob(key,sizeof(key),output,sizeof(output))==sizeof(bytes));assert(!memcmp(output,bytes,sizeof(bytes)));
+ assert(getBlob(NULL,0,output,sizeof(output))==0);
+ assert(getBlob("missing",7,output,sizeof(output))==0);
+ // Concurrent writers/readers must never return a partially written payload.
+ dispatch_apply(32,dispatch_get_global_queue(QOS_CLASS_DEFAULT,0),^(size_t n){
+ char payload[256];memset(payload,(int)n,sizeof(payload));
+ storeBlob(&n,sizeof(n),payload,sizeof(payload));
+ char readback[256];assert(getBlob(&n,sizeof(n),readback,sizeof(readback))==sizeof(payload));
+ assert(!memcmp(readback,payload,sizeof(payload)));
+ });
+ dispatch_sync(writer,^{});
+ // Sparse files exercise disk eviction without allocating 80 MiB of data.
+ for(int i=0;i<5;i++) {
+ NSString *path=[cacheDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"budget-%d.blob",i]];
+ int fd=open(path.fileSystemRepresentation,O_CREAT|O_RDWR,0600);assert(fd>=0);assert(!ftruncate(fd,16*1024*1024));close(fd);
+ }
+ trimDisk();NSUInteger total=0;
+ for(NSString *name in [NSFileManager.defaultManager contentsOfDirectoryAtPath:cacheDirectory error:nil])
+ total+=[[NSFileManager.defaultManager attributesOfItemAtPath:[cacheDirectory stringByAppendingPathComponent:name] error:nil] fileSize];
+ assert(total<=maximumDisk);
+ assert([NSFileManager.defaultManager removeItemAtPath:cacheDirectory error:nil]);
+ puts("PASS: shader cache disk reload, buffer bounds, concurrent writes and 64 MiB eviction");
+ return 0;
+}}
diff --git a/port/ios/tests/webrtc_probe.m b/port/ios/tests/webrtc_probe.m
new file mode 100644
index 0000000000..b303ac01b7
--- /dev/null
+++ b/port/ios/tests/webrtc_probe.m
@@ -0,0 +1,72 @@
+/* Opt-in real WebKit-to-Chromium test; game assets and iOS signing not needed. */
+#import
+#import
+#include "../network/room_bridge.h"
+#include "../../linux/src/posix.h"
+#include
+#include
+#include
+static int fail;
+struct address { uint16_t family, port; uint32_t ip; unsigned char padding[8]; };
+int main(int argc,char **argv) { @autoreleasepool {
+ if(argc!=3)return 2;
+ [NSApplication sharedApplication];
+ WKWebView *web=[[WKWebView alloc]initWithFrame:NSMakeRect(0,0,200,100) configuration:[WKWebViewConfiguration new]];
+ NSWindow *window=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,200,100) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
+ window.contentView=web;[window orderBack:nil];
+ NSMutableString *html=[NSMutableString stringWithString:@"",[[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding]];
+ [web loadHTMLString:html baseURL:[NSURL URLWithString:@"https://halo-native.invalid/"]];
+ struct address local={2,0x3412,0,{0}},remote={2,0x7856,0xffffffff,{0}};
+ int udp=posix_socket(2,2,0);posix_socket_bind(udp,&local,sizeof(local));posix_socket_set_nonblocking(udp,1);
+ __block BOOL busy=NO;__block uint32_t ack=0;__block int connected=0;
+ __block NSTimeInterval last_send=0;
+ NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:65];
+ unsigned char payload[4096];for(int i=0;i<4096;i++)payload[i]=(unsigned char)(i*37);
+ enum { STREAM_SIZE=256*1024 };
+ unsigned char *stream_data=malloc(STREAM_SIZE), *stream_reply=malloc(STREAM_SIZE);
+ for(int i=0;i0 && !fail){
+ if(!web.loading && !busy){
+ NSMutableData*out=[NSMutableData dataWithLength:256*1024];uint32_t end;
+ size_t count=ios_room_read_output(out.mutableBytes,out.length,&end);out.length=count;
+ NSData *request=[NSJSONSerialization dataWithJSONObject:@{@"out":[out base64EncodedStringWithOptions:0],@"ack":@(ack)} options:0 error:nil];
+ busy=YES;
+ [web evaluateJavaScript:[NSString stringWithFormat:@"HaloNative.exchange(%@)",[[NSString alloc]initWithData:request encoding:NSUTF8StringEncoding]] completionHandler:^(id value,NSError*error){
+ busy=NO;if(error){fprintf(stderr,"WebKit: %s\n",error.localizedDescription.UTF8String);fail=1;return;}
+ ios_room_ack_output(end-(uint32_t)count+[value[@"sent"] unsignedIntValue]);
+ NSData*incoming=[[NSData alloc]initWithBase64EncodedString:value[@"input"][@"data"] options:0];
+ if(incoming.length && ios_room_write_input(incoming.bytes,incoming.length))ack=[value[@"input"][@"id"] unsignedIntValue];
+ web_shared_state()->net_local_address=[value[@"address"] unsignedIntValue];
+ if([value[@"selected"] isKindOfClass:NSDictionary.class])connected=1;
+ if([value[@"error"] isKindOfClass:NSString.class]){fprintf(stderr,"Room: %s\n",[value[@"error"] UTF8String]);fail=1;}
+ }];
+ }
+ NSTimeInterval now=NSDate.timeIntervalSinceReferenceDate;
+ if(connected && !udp_ok && now-last_send>.5){posix_socket_sendto(udp,payload,sizeof(payload),0,&remote,sizeof(remote));last_send=now;}
+ unsigned char received[4096];struct address from;int from_size=sizeof(from);
+ int n=posix_socket_recvfrom(udp,received,sizeof(received),0,&from,&from_size);
+ if(!udp_ok && n==sizeof(payload) && !memcmp(payload,received,sizeof(payload))){
+ puts("PASS: native -> WebKit WebRTC -> Chromium -> native, 4096 exact datagram bytes");
+ udp_ok=1;stream=posix_socket(2,1,0);posix_socket_set_nonblocking(stream,1);
+ from.port=remote.port;
+ if(posix_socket_connect(stream,&from,sizeof(from))<0 && posix_socket_last_error()!=10035){fprintf(stderr,"stream connect failed\n");return 1;}
+ }
+ if(stream>=0){
+ if(sent0)sent+=written;}
+ if(received_count0)received_count+=got;}
+ if(received_count==STREAM_SIZE){
+ if(memcmp(stream_data,stream_reply,STREAM_SIZE)){fprintf(stderr,"FAIL: stream differs\n");return 1;}
+ printf("PASS: reliable stream round-trip, %d exact ordered bytes\n",STREAM_SIZE);
+ posix_socket_close(stream);posix_socket_close(udp);free(stream_data);free(stream_reply);return 0;
+ }
+ }
+ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:.008]];
+ }
+ fprintf(stderr,"FAIL: no native/browser round-trip before timeout (selection=%d)\n",connected);return 1;
+}}
diff --git a/port/ios/tests/xiso_probe.c b/port/ios/tests/xiso_probe.c
new file mode 100644
index 0000000000..4d3fa9ef43
--- /dev/null
+++ b/port/ios/tests/xiso_probe.c
@@ -0,0 +1,23 @@
+#include "xiso.h"
+#include
+#include
+#include
+static uint64_t cancel_after, previous;
+static int progress(void *context, const char *file, uint64_t done, uint64_t total) {
+ (void)context;(void)file;
+ if (donetotal) abort();
+ previous=done;
+ return !cancel_after || done4) cancel_after=strtoull(argv[4],NULL,10);
+ ok=xiso_extract_maps(argv[2],argv[3],progress,NULL,error,sizeof(error));
+ }
+ printf("%s: %s\n",ok?"OK":"ERROR",ok?"maps validated":error);
+ return ok?0:1;
+}
diff --git a/port/linux/HOST_MIGRATION.md b/port/linux/HOST_MIGRATION.md
new file mode 100644
index 0000000000..106253083f
--- /dev/null
+++ b/port/linux/HOST_MIGRATION.md
@@ -0,0 +1,126 @@
+# Browser match preservation
+
+A quick-play host departure or transport outage must never call the new-match startup path. The
+map, live player/object datums and game type stay loaded. The game thread pauses
+the simulation while the browser elects a replacement, then reconnects the
+original roster over newly established transports.
+
+The design borrows the trading platform's separation of authoritative ordering
+from observation delivery: one host assigns authority, checkpoints identify the
+last applied sequence, and recovery validates identity before applying state.
+The game implements these boundaries in-process; it does not need a separate
+sequencer or snapshotter service.
+
+## Ordering and checkpoints
+
+The room election epoch identifies the current host generation; the engine's
+game tick orders simulation state. An eight-byte epoch envelope around room
+packets rejects messages from previous hosts, including reliable packets queued
+behind native ring backpressure. Reattach and final resume handshakes also verify
+session seed, epoch, original machine slot and its recorded virtual address.
+
+Every 15 host ticks, reliable fragments carry a bounded, checksummed checkpoint.
+A partial, corrupt or invalid typed snapshot never replaces the last complete
+one. The checkpoint supplements the existing canonical replica with random
+state, respawn/death timers, game-type authority state, machine roster and routing, damage cooldowns,
+weapon firing/reload state and projectile state. Checkpoint metadata identifies
+which surviving player can safely adopt authority. Idle or still-loading clients
+cannot become a replacement host.
+
+## Resume
+
+The new server copies the same network roster and adopts the player's loaded
+world instead of invoking normal server creation, map selection or countdown.
+Survivors reconnect their existing machine/player slots; no player is added a
+second time. Only transport/input queues are rebuilt. The replacement fences
+the departed host, waits for the remaining machines to reattach, and broadcasts
+a final resume acknowledgement at the retained authority tick. After 12 seconds,
+reachable machines may resume while missing player slots and their recorded
+owners remain eligible to reattach for another 120 seconds. Validated disconnects
+also retain this identity during the original host's first, epoch-zero outage;
+transport failure alone must not remove the roster before recovery begins. Clients stay paused
+until that acknowledgement, rather than simulating ahead while the host waits.
+Late joins update roster membership and slot ownership, including when they reuse
+a departed machine's slot. Historical score datums remain on the scoreboard;
+their old machine input ownership is removed.
+
+If the original host is still present and is elected again, it renews its listener
+and client transports around the existing authoritative world. It keeps its own
+player and does not promote a replica or roll the world back. Reliable output
+queues are bounded per peer so one blocked recipient cannot stop every other
+player's keepalives. Queued reliable input is serviced before applying browser
+connection timeouts. A timed-out reconnect retires its stale transport and retries
+the same authority epoch; the former 45-second permanent recovery failure is
+removed. Room epoch synchronization follows the loaded match's identity.
+
+Authority announcements travel over both MQTT and each surviving RTC connection.
+The direct control message is bounded, tied to the current peer identity and
+shares a sequence gate with broker delivery, so delayed announcements cannot
+replace newer authority state. Changes, connection opens and periodic beacons
+repeat the announcement. During recovery, connected checkpoint owners of the
+same match can acknowledge and finish an election even if one or all brokers
+are offline. Initial room creation and an isolated survivor still require broker
+discovery; new or lost RTC connections need signaling for their ICE exchange.
+If a replacement disappears during reservation, recovery returns to candidate
+selection in the same epoch while retaining the loaded world. Progress messages
+distinguish room signaling, host selection and connection to a known replacement.
+
+A generic replay of player inputs is not used: the distributed client prediction
+path is not a deterministic copy of the authoritative simulation. Existing
+replication reconciles the resumed world, while sequenced checkpoints recover
+host-only state. Short checkpoint rollback and reconciliation are possible;
+zero interruption or exact historic collision replay is not claimed.
+A partitioned former host can reattach as its original player, using the recorded
+owner address, within the same 120-second grace. It follows the new host's epoch
+and cannot reclaim authority. The departed host does not delay the survivor cohort's
+resume; a truly absent former player expires after the grace.
+
+A client's native connection failure first repairs that player's stream to the
+current host in the same epoch, including epoch zero. The original slot, score,
+inventory and loaded world remain; other players keep running. A repaired stream
+replaces a half-open connection only after session, epoch, slot and recorded-owner
+validation. Admission and failed unvalidated attempts never invalidate a retained
+roster slot. The client stays paused until its native acknowledgement arrives.
+If the host disappears or its checkpoints stop advancing, bounded loss detection
+still permits failover. A candidate's higher proposed epoch cannot interrupt a
+healthy host or healthy clients. A launched, checkpointed replacement authority
+still fences older hosts after a real takeover. If the current host returns before
+a replacement commits, the isolated client retracts its proposal and reattaches.
+
+## Verification
+
+The tests exercise atomic checkpoint assembly, typed state validation and
+restoration, actual manager adoption/reattach/resume helpers, epoch and ownership
+rejection, current-roster retention, repeated handoff, and the production
+quick-play state machine. Browser tests fence delayed packets and ensure
+canceled sessions cannot receive recovery callbacks. A full WebAssembly build
+is required alongside these tests. Live multiplayer evidence must separately
+compare scores, clocks, player slots, positions and inventory across a host
+close; passing these tests alone is not public deployment or sustained play.
+
+The September 30 three-player Chrome check forced a 20-second host output outage
+while keeping that host running, and held one client's output for 60 seconds.
+All three resumed match 58499 with the original host at epoch 1 and retained
+player slots and accumulated scores. Closing that host afterward preserved the
+same match on both survivors at epoch 2. These are local injected-fault checks;
+they do not establish arbitrary WAN partition or browser-eviction recovery.
+
+A further three-player Chrome check disconnected player 3 from all signaling
+brokers before closing player 1's host tab. Player 2 became host; both survivors
+resumed match 26575 at epoch 1 while player 3 still had zero broker connections.
+The replacement announcement was recorded over RTC. Original player slots and
+accumulated scores remained in the same match. After signaling returned, a late
+joiner entered that match; closing player 2 then resumed both survivors at epoch 2.
+Regression checks also cover elections with all brokers unavailable, isolation,
+foreign-match joiners and a replacement dropping during reservation. The public
+package excludes the local fault injector and observe-mode launch arguments.
+
+The same-epoch repair check resumed one client at epoch zero while the host and
+third player kept playing and checkpoints kept advancing. It first reproduced
+admission invalidating the retained machine; the corrected admission and pending
+cleanup paths pass manager regressions whose original-code controls fail.
+In the final three-player Chrome run, match 21315 survived a single-client repair,
+then a native host failure: all three resumed at epoch one, including the former
+host's original player. Closing the replacement host then resumed both remaining
+players at epoch two, retaining original slots, accumulated scores and the loaded
+world. This release uses network version 6; older loaded runtimes are not hot-patched.
diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md
index 2c3913a781..c68463caf6 100644
--- a/port/linux/NETCODE.md
+++ b/port/linux/NETCODE.md
@@ -93,7 +93,9 @@ is dead; version 8 is the first whose clients play by the host's rules
(below), so a build without them joins no host of it; version 9 tells
every machine of a player the host dropped for cheating, each client
tells the host its Discord user, and a machine's join request carries its
-hardware id; version 10 sends every player's ping for the scoreboard.
+hardware id; version 10 sends every player's ping for the scoreboard;
+version 11 adds browser match-preserving host migration, typed checkpoints,
+and original-roster reattachment.
A client plays by its host's rules: in another's game (searching for it,
in its lobby, or playing it) the developer console, the telnet console
diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c
index 59838c3456..27b2365d88 100644
--- a/port/linux/game/menu_functions.c
+++ b/port/linux/game/menu_functions.c
@@ -112,7 +112,7 @@ void pc_menu_game_data_function_invoke(struct widget_instance *widget, long func
/* ---------- constants */
-#define MAXIMUM_STRINGS 64
+#define MAXIMUM_STRINGS 128
/* the PC version's custom activation event (this engine never sends it) */
#define EVENT_CUSTOM_ACTIVATION 32
#define BUTTON_A 0
diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c
index 4235cc139e..89eafeccab 100644
--- a/port/linux/game/menu_tags.c
+++ b/port/linux/game/menu_tags.c
@@ -77,7 +77,7 @@ adds to */
#define UNICODE_STRING_LIST_TAG 'ustr'
#define FONT_TAG 'font'
#define SOUND_TAG 'snd!'
-#define MAXIMUM_STRINGS 64
+#define MAXIMUM_STRINGS 128
#define MAXIMUM_TEXT 2048
/* the size of a menu bitmap's D3D texture, which only stands for its PNG */
#define PLACEHOLDER_SIZE 4
diff --git a/port/linux/game/network_checkpoint.h b/port/linux/game/network_checkpoint.h
new file mode 100644
index 0000000000..da2ff98d27
--- /dev/null
+++ b/port/linux/game/network_checkpoint.h
@@ -0,0 +1,74 @@
+/* Atomic, bounded assembly of a migration checkpoint. A partial transfer never
+replaces the previous complete checkpoint. Transport authenticates its sender. */
+#ifndef __NETWORK_CHECKPOINT_H
+#define __NETWORK_CHECKPOINT_H
+
+#include
+
+struct network_checkpoint_manifest
+{
+ unsigned long epoch;
+ long tick;
+ unsigned long size;
+ unsigned long checksum;
+};
+
+struct network_checkpoint_store
+{
+ unsigned char *staging;
+ unsigned char *complete;
+ unsigned long capacity;
+ unsigned long received;
+ struct network_checkpoint_manifest pending;
+ struct network_checkpoint_manifest committed;
+ int valid;
+ int (*validate)(void const *, unsigned long);
+};
+
+static unsigned long network_checkpoint_checksum(void const *buffer, unsigned long size)
+{
+ unsigned char const *bytes = (unsigned char const *)buffer;
+ unsigned long checksum = 2166136261UL;
+ unsigned long index;
+ for (index = 0; index < size; index++)
+ checksum = ((checksum ^ bytes[index]) * 16777619UL) & 0xffffffffUL;
+ return checksum;
+}
+
+/* Returns 1 only when this fragment completes a verified checkpoint. The
+strict offset order is intentional: the enclosing channel is reliable. */
+static int network_checkpoint_receive(struct network_checkpoint_store *store,
+ struct network_checkpoint_manifest const *manifest, unsigned long epoch,
+ unsigned long offset, void const *bytes, unsigned long size)
+{
+ if (manifest->epoch != epoch || manifest->tick < 0 || !manifest->size ||
+ manifest->size > store->capacity || !size || offset > manifest->size ||
+ size > manifest->size - offset || (store->valid &&
+ (manifest->epoch < store->committed.epoch || (manifest->epoch == store->committed.epoch &&
+ manifest->tick <= store->committed.tick))))
+ return 0;
+ if (!offset)
+ {
+ store->pending = *manifest;
+ store->received = 0;
+ }
+ if (store->pending.epoch != manifest->epoch || store->pending.tick != manifest->tick ||
+ store->pending.size != manifest->size || store->pending.checksum != manifest->checksum ||
+ offset != store->received)
+ return 0;
+ memcpy(store->staging + offset, bytes, size);
+ store->received += size;
+ if (store->received != manifest->size)
+ return 0;
+ store->received = 0;
+ if (network_checkpoint_checksum(store->staging, manifest->size) != manifest->checksum)
+ return 0;
+ if (store->validate && !store->validate(store->staging, manifest->size))
+ return 0;
+ memcpy(store->complete, store->staging, manifest->size);
+ store->committed = *manifest;
+ store->valid = 1;
+ return 1;
+}
+
+#endif
diff --git a/port/linux/game/network_damage.c b/port/linux/game/network_damage.c
index 0c23555d98..5e73659c9f 100644
--- a/port/linux/game/network_damage.c
+++ b/port/linux/game/network_damage.c
@@ -337,7 +337,7 @@ static short damage_event_count;
/* ... dealing a client's report */
static boolean damage_dealing_report;
/* ... each player's weapons of late, and hits left */
-static struct
+static struct damage_player_authority
{
long definition_indices[MAXIMUM_RECENT_WEAPONS];
long times[MAXIMUM_RECENT_WEAPONS];
@@ -417,6 +417,12 @@ static short damage_report_count;
/* ... replaying the host's killing blow (its player effect came before),
and its killer and their score after it (NONE: none) */
static boolean damage_replaying_kill;
+static boolean damage_migration_replaying;
+
+void network_damage_migration_replay(boolean replaying)
+{
+ damage_migration_replaying = replaying;
+}
static long damage_replaying_killer;
static long damage_replaying_killer_score;
@@ -599,6 +605,10 @@ boolean network_damage_deals(
real_vector3d const *object_normal,
boolean authorized)
{
+ if (damage_migration_replaying)
+ return FALSE;
+ if (!network_game_distributed())
+ return TRUE;
if (game_connection() == _game_connection_network_client)
{
struct unit_datum *unit;
@@ -2133,3 +2143,101 @@ void network_damage_new_game(
for (player_index = 0; player_index < DAMAGE_RATE_CACHE_SIZE; player_index++)
damage_rate_cache[player_index].source_index = NONE;
}
+
+struct migration_damage_state
+{
+ long player_count;
+ long times[TARGET_HISTORY_TICKS];
+};
+
+struct migration_damage_player
+{
+ long player_index;
+ struct damage_player_authority authority;
+ struct
+ {
+ long object_index;
+ real_point3d position;
+ real speed;
+ } targets[TARGET_HISTORY_TICKS][2];
+};
+
+long network_damage_migration_write(byte *buffer, long size)
+{
+ struct migration_damage_state state;
+ long offset = sizeof(state);
+ short player;
+ short tick;
+ if (size < offset)
+ return 0;
+ memset(&state, 0, sizeof(state));
+ for (tick = 0; tick < TARGET_HISTORY_TICKS; tick++)
+ state.times[tick] = damage_history[tick].time;
+ for (player = 0; player < MAXIMUM_TRACKED_PLAYERS; player++)
+ {
+ struct migration_damage_player record;
+ if (!distributed_player(player))
+ continue;
+ if (size - offset < (long)sizeof(record))
+ return 0;
+ memset(&record, 0, sizeof(record));
+ record.player_index = player;
+ record.authority = damage_players[player];
+ for (tick = 0; tick < TARGET_HISTORY_TICKS; tick++)
+ memcpy(record.targets[tick], damage_history[tick].objects[player], sizeof(record.targets[tick]));
+ memcpy(buffer + offset, &record, sizeof(record));
+ offset += sizeof(record);
+ state.player_count++;
+ }
+ memcpy(buffer, &state, sizeof(state));
+ return offset;
+}
+
+boolean network_damage_migration_validate(byte const *buffer, long size)
+{
+ struct migration_damage_state state;
+ byte seen[MAXIMUM_TRACKED_PLAYERS];
+ long index;
+ if (size < (long)sizeof(state))
+ return FALSE;
+ memcpy(&state, buffer, sizeof(state));
+ if (state.player_count < 0 || state.player_count > MAXIMUM_TRACKED_PLAYERS ||
+ size - (long)sizeof(state) != state.player_count * (long)sizeof(struct migration_damage_player))
+ return FALSE;
+ memset(seen, 0, sizeof(seen));
+ for (index = 0; index < state.player_count; index++)
+ {
+ struct migration_damage_player record;
+ memcpy(&record, buffer + sizeof(state) + index * sizeof(record), sizeof(record));
+ if (record.player_index < 0 || record.player_index >= MAXIMUM_TRACKED_PLAYERS || seen[record.player_index])
+ return FALSE;
+ seen[record.player_index] = 1;
+ }
+ return TRUE;
+}
+
+boolean network_damage_migration_restore(byte const *buffer, long size)
+{
+ struct migration_damage_state state;
+ long index;
+ short tick;
+ if (!network_damage_migration_validate(buffer, size))
+ return FALSE;
+ memcpy(&state, buffer, sizeof(state));
+ for (tick = 0; tick < TARGET_HISTORY_TICKS; tick++)
+ damage_history[tick].time = state.times[tick];
+ for (index = 0; index < state.player_count; index++)
+ {
+ struct migration_damage_player record;
+ memcpy(&record, buffer + sizeof(state) + index * sizeof(record), sizeof(record));
+ damage_players[record.player_index] = record.authority;
+ for (tick = 0; tick < TARGET_HISTORY_TICKS; tick++)
+ memcpy(damage_history[tick].objects[record.player_index], record.targets[tick], sizeof(record.targets[tick]));
+ }
+ /* Pending local hit reports remain queued for the new host. Old outgoing
+visual damage events must not be announced twice after the handover. */
+ damage_event_count = 0;
+ damage_dealing_report = FALSE;
+ damage_replaying_kill = FALSE;
+ return TRUE;
+}
diff --git a/port/linux/game/network_distributed.c b/port/linux/game/network_distributed.c
index 2b773cc68c..601d5c8733 100644
--- a/port/linux/game/network_distributed.c
+++ b/port/linux/game/network_distributed.c
@@ -66,6 +66,9 @@ machine (their datum identifiers need not be).
#include "units/biped_definitions.h"
#include "units/bipeds.h"
#include "network_distributed.h"
+#include "network_checkpoint.h"
+#include "networking/network_migration.h"
+#include "game/game_engine.h"
#include
#include
@@ -274,6 +277,52 @@ enum
HIDDEN_PLAYER_PERIOD_TICKS = 6,
};
+enum
+{
+ MIGRATION_CHECKPOINT_VERSION = 2,
+ MIGRATION_CHECKPOINT_INTERVAL_TICKS = 15,
+ MIGRATION_CHECKPOINT_CAPACITY = 4 * 1024 * 1024,
+ MIGRATION_CHECKPOINT_FRAGMENT_SIZE = 0xE00,
+};
+
+struct migration_player_state
+{
+ short player_index;
+ short pad;
+ long unit_index;
+ long dead_unit_index;
+ long respawn_timer;
+ long respawn_penalty;
+ long death_time;
+ long telefrag_timeout;
+ long quit_out_of_game_time;
+ boolean quit_out_of_game;
+ boolean is_blocking_teleporter;
+ byte pad1[2];
+};
+
+struct migration_checkpoint_state
+{
+ long version;
+ unsigned long session_seed;
+ unsigned long random_seed;
+ long engine_size;
+ long object_size;
+ long damage_size;
+ long player_count;
+ long host_machine_index;
+ unsigned long machine_addresses[HALO_PORT_MAXIMUM_NETWORK_MACHINES];
+ struct network_migration_membership membership;
+};
+
+struct migration_checkpoint_message
+{
+ struct distributed_message_header header;
+ struct network_checkpoint_manifest manifest;
+ unsigned long offset;
+ byte data[MIGRATION_CHECKPOINT_FRAGMENT_SIZE];
+};
+
/* shields, health and the damage they show in 16 bits: 0 to 4 */
#define VITALITY_SCALE 16384.0f
@@ -447,6 +496,15 @@ struct distributed_packer
/* ---------- globals */
static long distributed_last_sent_time = NONE;
+static byte migration_checkpoint_staging[MIGRATION_CHECKPOINT_CAPACITY];
+static byte migration_checkpoint_complete[MIGRATION_CHECKPOINT_CAPACITY];
+static byte migration_checkpoint_outgoing[MIGRATION_CHECKPOINT_CAPACITY];
+static struct network_checkpoint_store migration_checkpoint_store =
+{
+ migration_checkpoint_staging, migration_checkpoint_complete,
+ MIGRATION_CHECKPOINT_CAPACITY, 0, { 0 }, { 0 }, 0, NULL
+};
+static long migration_latest_game_state_tick = NONE;
/* how each player last died, by absolute index: the host's own, which it
sends its clients, and a client's copy of the host's */
@@ -3058,6 +3116,280 @@ static void distributed_send_game_state(
_distributed_to_clients_reliably);
}
+/* ---------- live authority checkpoints */
+
+static unsigned long distributed_migration_epoch(void)
+{
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ return network_game_migration_epoch();
+#else
+ return 0;
+#endif
+}
+
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+static boolean distributed_checkpoint_validate_ownership(struct migration_checkpoint_state const *state)
+{
+ long index, other;
+ if (!network_game_client_migration_validate_machines(&state->membership, sizeof(state->membership)) ||
+ state->host_machine_index < 0 || state->host_machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES ||
+ state->membership.machines[state->host_machine_index].machine_index != state->host_machine_index)
+ return FALSE;
+ for (index = 0; index < HALO_PORT_MAXIMUM_NETWORK_MACHINES; index++)
+ {
+ boolean present = state->membership.machines[index].machine_index != NONE;
+ if (present != (state->machine_addresses[index] != 0))
+ return FALSE;
+ if (!present)
+ continue;
+ for (other = 0; other < index; other++)
+ {
+ if (state->machine_addresses[other] == state->machine_addresses[index])
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+#endif
+
+static boolean distributed_checkpoint_validate(byte const *buffer, long size)
+{
+ struct migration_checkpoint_state state;
+ long offset;
+ long index;
+ byte seen[MAXIMUM_TRACKED_PLAYERS];
+ if (size < (long)sizeof(state))
+ return FALSE;
+ memcpy(&state, buffer, sizeof(state));
+ if (state.version != MIGRATION_CHECKPOINT_VERSION ||
+ state.session_seed != (unsigned long)network_game_get_random_seed() ||
+ state.player_count < 0 || state.player_count > MAXIMUM_TRACKED_PLAYERS ||
+ state.host_machine_index < 0 || state.host_machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES ||
+ state.engine_size <= 0 || state.engine_size > MAXIMUM_GAME_STATE_SIZE ||
+ state.object_size <= 0 || state.damage_size <= 0)
+ return FALSE;
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ if (!distributed_checkpoint_validate_ownership(&state))
+ return FALSE;
+#endif
+ offset = sizeof(state) + state.player_count * sizeof(struct migration_player_state);
+ if (offset > size || state.engine_size > size - offset ||
+ state.object_size > size - offset - state.engine_size ||
+ state.damage_size != size - offset - state.engine_size - state.object_size ||
+ !game_engine_validate_migration_state(buffer + offset, state.engine_size) ||
+ !network_objects_migration_validate(buffer + offset + state.engine_size, state.object_size) ||
+ !network_damage_migration_validate(buffer + offset + state.engine_size + state.object_size, state.damage_size))
+ return FALSE;
+ memset(seen, 0, sizeof(seen));
+ for (index = 0; index < state.player_count; index++)
+ {
+ struct migration_player_state player;
+ memcpy(&player, buffer + sizeof(state) + index * sizeof(player), sizeof(player));
+ if (player.player_index < 0 || player.player_index >= MAXIMUM_TRACKED_PLAYERS || seen[player.player_index])
+ return FALSE;
+ seen[player.player_index] = 1;
+ }
+ return TRUE;
+}
+
+static void distributed_checkpoint_notify(void)
+{
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ void web_quick_play_checkpoint(unsigned long epoch, long tick, unsigned long seed);
+ struct migration_checkpoint_state state;
+ memcpy(&state, migration_checkpoint_store.complete, sizeof(state));
+ web_quick_play_checkpoint(migration_checkpoint_store.committed.epoch,
+ migration_checkpoint_store.committed.tick, state.session_seed);
+#endif
+}
+
+static int distributed_checkpoint_validate_received(void const *buffer, unsigned long size)
+{
+ return distributed_checkpoint_validate((byte const *)buffer, (long)size);
+}
+
+static void distributed_send_checkpoint(void)
+{
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ struct migration_checkpoint_state state;
+ struct migration_checkpoint_message message;
+ long offset = sizeof(state);
+ short player_index;
+ long size;
+ if (!web_match_migration_enabled())
+ return;
+ memset(&state, 0, sizeof(state));
+ state.version = MIGRATION_CHECKPOINT_VERSION;
+ state.session_seed = (unsigned long)network_game_get_random_seed();
+ state.random_seed = *get_global_random_seed_address();
+ {
+ short network_game_client_get_machine_index(struct network_game_client *client);
+ state.host_machine_index = network_game_client_get_machine_index(global_network_game_client_get());
+ }
+ network_game_server_migration_routes(state.machine_addresses, HALO_PORT_MAXIMUM_NETWORK_MACHINES);
+ if (!network_game_server_migration_machines(&state.membership, sizeof(state.membership)))
+ return;
+ for (player_index = 0; player_index < MAXIMUM_TRACKED_PLAYERS; player_index++)
+ {
+ struct player_datum *player = distributed_player(player_index);
+ struct migration_player_state record;
+ if (!player)
+ continue;
+ memset(&record, 0, sizeof(record));
+ record.player_index = player_index;
+ record.unit_index = player->unit_index;
+ record.dead_unit_index = player->dead_unit_index;
+ record.respawn_timer = player->respawn_timer;
+ record.respawn_penalty = player->respawn_penalty;
+ record.death_time = player->death_time;
+ record.telefrag_timeout = player->telefrag_timeout;
+ record.quit_out_of_game_time = player->quit_out_of_game_time;
+ record.quit_out_of_game = player->quit_out_of_game;
+ record.is_blocking_teleporter = player->is_blocking_teleporter;
+ memcpy(migration_checkpoint_outgoing + offset, &record, sizeof(record));
+ offset += sizeof(record);
+ state.player_count++;
+ }
+ state.engine_size = game_engine_write_migration_state(migration_checkpoint_outgoing + offset,
+ MAXIMUM_GAME_STATE_SIZE);
+ if (!state.engine_size)
+ return;
+ offset += state.engine_size;
+ state.object_size = network_objects_migration_write(migration_checkpoint_outgoing + offset,
+ MIGRATION_CHECKPOINT_CAPACITY - offset);
+ if (!state.object_size)
+ return;
+ offset += state.object_size;
+ state.damage_size = network_damage_migration_write(migration_checkpoint_outgoing + offset,
+ MIGRATION_CHECKPOINT_CAPACITY - offset);
+ if (!state.damage_size)
+ return;
+ size = offset + state.damage_size;
+ memcpy(migration_checkpoint_outgoing, &state, sizeof(state));
+ message.manifest.epoch = distributed_migration_epoch();
+ message.manifest.tick = game_time_get();
+ message.manifest.size = size;
+ message.manifest.checksum = network_checkpoint_checksum(migration_checkpoint_outgoing, size);
+ for (offset = 0; offset < size; offset += MIGRATION_CHECKPOINT_FRAGMENT_SIZE)
+ {
+ long length = MIN(MIGRATION_CHECKPOINT_FRAGMENT_SIZE, size - offset);
+ message.offset = offset;
+ memcpy(message.data, migration_checkpoint_outgoing + offset, length);
+ distributed_send(&message, _distributed_message_migration_checkpoint, 0,
+ (word)(offsetof(struct migration_checkpoint_message, data) + length), _distributed_to_clients_reliably);
+ }
+ /* The current host is also a valid participant if a partition reunites. */
+ memcpy(migration_checkpoint_store.complete, migration_checkpoint_outgoing, size);
+ migration_checkpoint_store.committed = message.manifest;
+ migration_checkpoint_store.valid = 1;
+ distributed_checkpoint_notify();
+#endif
+}
+
+static void distributed_receive_checkpoint(void const *message, word size)
+{
+ struct migration_checkpoint_message part;
+ long prefix = offsetof(struct migration_checkpoint_message, data);
+ if (size <= prefix)
+ return;
+ memcpy(&part, message, prefix);
+ if (part.manifest.tick != part.header.game_time ||
+ size - prefix > MIGRATION_CHECKPOINT_FRAGMENT_SIZE)
+ return;
+ migration_checkpoint_store.validate = distributed_checkpoint_validate_received;
+ if (network_checkpoint_receive(&migration_checkpoint_store, &part.manifest,
+ distributed_migration_epoch(), part.offset, (byte const *)message + prefix, size - prefix))
+ {
+ struct migration_checkpoint_state state;
+ memcpy(&state, migration_checkpoint_store.complete, sizeof(state));
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ network_game_client_migration_set_machines(&state.membership, sizeof(state.membership));
+ network_game_client_migration_routes(state.machine_addresses, HALO_PORT_MAXIMUM_NETWORK_MACHINES);
+ network_game_client_migration_set_host_machine((short)state.host_machine_index);
+#endif
+ distributed_checkpoint_notify();
+ }
+}
+
+boolean network_distributed_migration_ready(void)
+{
+ return network_game_distributed() && game_in_progress() && migration_checkpoint_store.valid &&
+ migration_checkpoint_store.committed.epoch == distributed_migration_epoch();
+}
+
+long network_distributed_migration_tick(void)
+{
+ if (!network_distributed_migration_ready())
+ return NONE;
+ /* Local prediction continues until transport loss is detected. Resume at
+the last host tick, not at those unconfirmed locally simulated ticks. */
+ return MAX(migration_checkpoint_store.committed.tick, distributed_host_time);
+}
+
+boolean network_distributed_migration_promote(void)
+{
+ struct migration_checkpoint_state state;
+ byte const *buffer = migration_checkpoint_store.complete;
+ long tick = network_distributed_migration_tick();
+ long elapsed;
+ long offset;
+ long index;
+ if (tick == NONE || !distributed_checkpoint_validate(buffer, migration_checkpoint_store.committed.size))
+ return FALSE;
+ memcpy(&state, buffer, sizeof(state));
+ elapsed = tick - migration_checkpoint_store.committed.tick;
+ offset = sizeof(state) + state.player_count * sizeof(struct migration_player_state);
+ if (!network_objects_migration_restore(buffer + offset + state.engine_size, state.object_size, elapsed))
+ return FALSE;
+ network_damage_migration_restore(buffer + offset + state.engine_size + state.object_size, state.damage_size);
+ for (index = 0; index < state.player_count; index++)
+ {
+ struct migration_player_state record;
+ struct player_datum *player;
+ memcpy(&record, buffer + sizeof(state) + index * sizeof(record), sizeof(record));
+ player = distributed_player(record.player_index);
+ if (!player)
+ continue;
+ /* A spawn/death received after the snapshot owns the unit binding. */
+ if (player->unit_index == record.unit_index && player->dead_unit_index == record.dead_unit_index)
+ {
+ player->respawn_timer = record.respawn_timer > 0 ? MAX(1, record.respawn_timer - elapsed) : 0;
+ player->death_time = record.death_time;
+ }
+ player->respawn_penalty = record.respawn_penalty;
+ player->telefrag_timeout = record.telefrag_timeout;
+ player->quit_out_of_game_time = record.quit_out_of_game_time;
+ player->quit_out_of_game = record.quit_out_of_game;
+ player->is_blocking_teleporter = record.is_blocking_teleporter;
+ }
+ game_engine_read_migration_state(buffer + offset, state.engine_size,
+ migration_latest_game_state_tick <= migration_checkpoint_store.committed.tick);
+ *get_global_random_seed_address() = state.random_seed;
+ game_time_set_distributed(tick);
+ return TRUE;
+}
+
+void network_distributed_migration_reset_transport(void)
+{
+ short sender;
+ short type;
+ distributed_last_sent_time = NONE;
+ memset(distributed_predictions, 0, sizeof(distributed_predictions));
+ memset(distributed_round_trips, 0, sizeof(distributed_round_trips));
+ memset(distributed_seen, 0, sizeof(distributed_seen));
+ memset(distributed_sent_statistics, 0, sizeof(distributed_sent_statistics));
+ memset(distributed_seat_disagreements, 0, sizeof(distributed_seat_disagreements));
+ for (sender = 0; sender < MAXIMUM_SENDERS; sender++)
+ {
+ distributed_batches[sender].size = 0;
+ for (type = 0; type < NUMBER_OF_DISTRIBUTED_MESSAGES; type++)
+ distributed_received_times[sender][type] = NONE;
+ }
+ distributed_statistics_due = TRUE;
+ distributed_pickup_count = 0;
+ network_objects_migration_reset_transport();
+}
+
/* ---------- the game */
/* a new map loading (game.c), before any of the new game's messages can
@@ -3128,6 +3460,9 @@ void network_distributed_new_game(
distributed_machines.in_tick = FALSE;
distributed_machines.valid = FALSE;
distributed_host_time = NONE;
+ migration_checkpoint_store.valid = 0;
+ migration_checkpoint_store.received = 0;
+ migration_latest_game_state_tick = NONE;
distributed_statistics_due = FALSE;
distributed_pickup_count = 0;
/* (each player's latest input: player_queues_new.c) */
@@ -3180,6 +3515,8 @@ void network_distributed_tick(
distributed_send_pickups();
if (game_time_get() % GAME_STATE_INTERVAL_TICKS == 0)
distributed_send_game_state(NONE);
+ if (game_time_get() % MIGRATION_CHECKPOINT_INTERVAL_TICKS == 0)
+ distributed_send_checkpoint();
}
else if (connection == _game_connection_network_client)
{
@@ -3656,6 +3993,7 @@ void network_distributed_handle_message(
case _distributed_message_player_inputs: entry_size = sizeof(struct distributed_player_input); break;
case _distributed_message_relayed_actions: entry_size = DISTRIBUTED_RELAYED_ACTION_MINIMUM_SIZE; break;
case _distributed_message_game_state:
+ case _distributed_message_migration_checkpoint:
case _distributed_message_objects_synchronized:
case _distributed_message_notice:
case _distributed_message_client_ready: entry_size = 0; break;
@@ -3753,8 +4091,12 @@ void network_distributed_handle_message(
network_objects_handle_states(entries, header.count);
break;
case _distributed_message_game_state:
+ migration_latest_game_state_tick = header.game_time;
game_engine_read_network_state((byte const *)entries, size - sizeof(header));
break;
+ case _distributed_message_migration_checkpoint:
+ distributed_receive_checkpoint(message, size);
+ break;
case _distributed_message_objects_synchronized:
network_objects_handle_synchronized();
break;
diff --git a/port/linux/game/network_distributed.h b/port/linux/game/network_distributed.h
index b55c64cc5d..968f1597d3 100644
--- a/port/linux/game/network_distributed.h
+++ b/port/linux/game/network_distributed.h
@@ -56,6 +56,8 @@ enum
_distributed_message_relayed_actions,
/* the unreliable messages of a tick to one machine, in one datagram */
_distributed_message_batch,
+ /* atomic authoritative handover checkpoint (reliable fragments) */
+ _distributed_message_migration_checkpoint,
/* the host's text to every client, which shows it in red on its
console (a player dropped for cheating: distributed_note_client_clock) */
_distributed_message_notice,
@@ -162,6 +164,11 @@ short distributed_client_machines(long *machine_indices, short maximum);
/* (the host) how long a message takes that client and its answer back, in
ticks (and its jitter), as its players' input messages tell */
real distributed_machine_round_trip_ticks(long machine_index);
+boolean network_distributed_migration_ready(void);
+long network_distributed_migration_tick(void);
+boolean network_distributed_migration_promote(void);
+/* Connection caches only; the live map, players and objects remain intact. */
+void network_distributed_migration_reset_transport(void);
/* (a client) the host's latest tick it has had a message of, NONE for none */
long distributed_latest_host_time(void);
/* (a client) how long the host takes to have this machine's players and
@@ -215,6 +222,10 @@ void network_objects_apply_vehicle_predictions(void);
word network_objects_entry_size(byte type);
/* whether this client has the host's object at this index */
boolean network_objects_client_has(long object_index);
+long network_objects_migration_write(byte *buffer, long size);
+boolean network_objects_migration_validate(byte const *buffer, long size);
+boolean network_objects_migration_restore(byte const *buffer, long size, long elapsed_ticks);
+void network_objects_migration_reset_transport(void);
/* moves the object where the host has it, drawn gliding from where it was */
void network_objects_correct(long object_index, real_point3d const *position, real_vector3d const *forward,
real_vector3d const *up, real_vector3d const *velocity, real_vector3d const *angular_velocity);
@@ -234,5 +245,10 @@ void network_damage_client_tick(void);
void network_damage_handle_events(void const *entries, short count);
void network_damage_handle_reports(long machine_index, void const *entries, short count);
word network_damage_entry_size(byte type);
+/* Projectile catchup advances its physics without repeating committed damage. */
+void network_damage_migration_replay(boolean replaying);
+long network_damage_migration_write(byte *buffer, long size);
+boolean network_damage_migration_validate(byte const *buffer, long size);
+boolean network_damage_migration_restore(byte const *buffer, long size);
#endif // __NETWORK_DISTRIBUTED_H
diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c
index e592f6af46..41a1a62066 100644
--- a/port/linux/game/network_objects.c
+++ b/port/linux/game/network_objects.c
@@ -57,6 +57,7 @@ same datum index (identifier and all), so that any message can name one:
#include "units/vehicle_datum.h"
#include "items/items.h"
#include "items/weapons.h"
+#include "items/projectiles.h"
#include "items/weapon_definitions.h"
#include "items/equipment_definitions.h"
#include "network_distributed.h"
@@ -440,6 +441,10 @@ static boolean objects_client_ask_again;
long until it asks again */
static boolean objects_client_synchronized;
static long objects_client_ready_time;
+/* During reattachment keep the surviving world until the host has replayed
+its current object identities. Then retire only generations absent from it. */
+static boolean objects_client_resynchronizing;
+static boolean objects_client_resync_seen[MAXIMUM_TRACKED_OBJECTS];
static long objects_client_ready_interval;
/* ... when it last failed to make one (NONE: not since the host last told
it all), and how long it waits after the next failure */
@@ -1977,6 +1982,8 @@ void network_objects_handle_changes(
if (change->change == _object_change_create)
{
distributed_client_create(change);
+ if (objects_client_resynchronizing && objects_client_has[absolute_index] == change->object_index)
+ objects_client_resync_seen[absolute_index] = TRUE;
}
else if (change->change == _object_change_delete &&
objects_client_has[absolute_index] == change->object_index)
@@ -1991,6 +1998,7 @@ void network_objects_handle_changes(
void network_objects_handle_synchronized(
void)
{
+ long absolute_index;
/* (a create failed since this machine last asked: not all of them, it
asks again) */
if (objects_client_failed_time != NONE)
@@ -2000,6 +2008,17 @@ void network_objects_handle_synchronized(
objects_client_failed_time = NONE;
objects_client_retry_ticks = CLIENT_RETRY_TICKS;
}
+ if (objects_client_resynchronizing)
+ {
+ for (absolute_index = 0; absolute_index < MAXIMUM_TRACKED_OBJECTS; absolute_index++)
+ {
+ if (objects_client_has[absolute_index] == NONE || objects_client_resync_seen[absolute_index])
+ continue;
+ distributed_client_delete(objects_client_has[absolute_index]);
+ objects_client_has[absolute_index] = NONE;
+ }
+ objects_client_resynchronizing = FALSE;
+ }
objects_client_synchronized = TRUE;
}
@@ -2665,6 +2684,8 @@ void network_objects_new_game(
}
objects_client_synchronized = FALSE;
objects_client_ready_time = NONE;
+ objects_client_resynchronizing = FALSE;
+ csmemset(objects_client_resync_seen, 0, sizeof(objects_client_resync_seen));
objects_client_ready_interval = CLIENT_READY_INTERVAL_TICKS;
objects_client_failed_time = NONE;
objects_client_retry_ticks = CLIENT_RETRY_TICKS;
@@ -2688,3 +2709,238 @@ void network_objects_new_game(
}
}
}
+
+/* A handover snapshot supplements the visible replica with authority-only
+cooldowns, item ages, weapon reload/heat state and in-flight projectiles.
+These records contain values and canonical object indices, never pointers,
+cluster-list links, renderer caches, or local audio/effect identifiers. */
+struct migration_object_state
+{
+ struct distributed_object_change identity;
+ long shield_damage_decay_timer;
+ long body_damage_decay_timer;
+ short shield_stun_ticks;
+ short type;
+ long owner_object_index;
+ long parent_object_index;
+ short parent_node_index;
+ short pad;
+ struct _item_datum item;
+ struct _weapon_datum weapon;
+ struct _projectile_datum projectile;
+};
+
+long network_objects_migration_write(byte *buffer, long size)
+{
+ struct object_iterator iterator;
+ struct object_datum *object;
+ long count = 0;
+ long offset = sizeof(count);
+ if (size < offset)
+ return 0;
+ object_iterator_new(&iterator, NETWORKED_OBJECT_TYPES | _object_mask_projectile, 0);
+ while ((object = object_iterator_next(&iterator)) != NULL)
+ {
+ struct migration_object_state state;
+ if (size - offset < (long)sizeof(state))
+ return 0;
+ memset(&state, 0, sizeof(state));
+ distributed_change_from_object(iterator.index, &state.identity);
+ state.type = object->object.type;
+ state.shield_damage_decay_timer = object->object.shield_damage_decay_timer;
+ state.body_damage_decay_timer = object->object.body_damage_decay_timer;
+ state.shield_stun_ticks = object->object.shield_stun_ticks;
+ state.owner_object_index = object->object.owner_object_index;
+ state.parent_object_index = object->object.parent_object_index;
+ state.parent_node_index = object->object.parent_node_index;
+ if (state.type == _object_type_projectile && state.parent_object_index != NONE)
+ {
+ object_get_origin(iterator.index, &state.identity.position);
+ object_get_orientation(iterator.index, &state.identity.forward, &state.identity.up);
+ }
+ if (TEST_FLAG(_object_mask_item | _object_mask_projectile, state.type))
+ state.item = ((struct item_datum *)object)->item;
+ if (state.type == _object_type_weapon)
+ {
+ short trigger;
+ state.weapon = ((struct weapon_datum *)object)->weapon;
+ /* Effects are local allocations, even when their object is shared. */
+ state.weapon.overheated_effect_index = NONE;
+ for (trigger = 0; trigger < 2; trigger++)
+ state.weapon.triggers[trigger].charging_effect_index = NONE;
+ }
+ if (state.type == _object_type_projectile)
+ {
+ state.projectile = ((struct projectile_datum *)object)->projectile;
+ state.projectile.tracer_attachment_index = NONE;
+ }
+ memcpy(buffer + offset, &state, sizeof(state));
+ offset += sizeof(state);
+ count++;
+ }
+ memcpy(buffer, &count, sizeof(count));
+ return offset;
+}
+
+boolean network_objects_migration_validate(byte const *buffer, long size)
+{
+ long count;
+ long index;
+ byte seen[MAXIMUM_TRACKED_OBJECTS];
+ if (size < (long)sizeof(count))
+ return FALSE;
+ memcpy(&count, buffer, sizeof(count));
+ if (count < 0 || count > MAXIMUM_TRACKED_OBJECTS ||
+ size - (long)sizeof(count) != count * (long)sizeof(struct migration_object_state))
+ return FALSE;
+ memset(seen, 0, sizeof(seen));
+ for (index = 0; index < count; index++)
+ {
+ struct migration_object_state state;
+ long absolute;
+ memcpy(&state, buffer + sizeof(count) + index * sizeof(state), sizeof(state));
+ absolute = DATUM_INDEX_TO_ABSOLUTE_INDEX(state.identity.object_index);
+ if (absolute < 0 || absolute >= MAXIMUM_TRACKED_OBJECTS || seen[absolute] ||
+ state.type < 0 || state.type >= NUMBER_OF_OBJECT_TYPES ||
+ !TEST_FLAG(NETWORKED_OBJECT_TYPES | _object_mask_projectile, state.type) ||
+ state.identity.definition_index == NONE ||
+ state.identity.owner_player_index != NO_PLAYER &&
+ state.identity.owner_player_index >= MAXIMUM_TRACKED_PLAYERS)
+ return FALSE;
+ seen[absolute] = 1;
+ }
+ return TRUE;
+}
+
+static boolean network_objects_migration_weapon_local(struct object_datum const *object)
+{
+ struct unit_datum *owner = (struct unit_datum *)object_try_and_get_and_verify_type(
+ object->object.parent_object_index, _object_mask_unit);
+ return owner && distributed_player_is_local(owner->unit.player_index);
+}
+
+boolean network_objects_migration_restore(byte const *buffer, long size, long elapsed_ticks)
+{
+ long count;
+ long index;
+ if (!network_objects_migration_validate(buffer, size))
+ return FALSE;
+ memcpy(&count, buffer, sizeof(count));
+ /* Locally predicted projectiles are outside the canonical world. Replace
+them with the checkpoint's exact generations before assuming authority. */
+ {
+ struct object_iterator iterator;
+ struct object_datum *object;
+ object_iterator_new(&iterator, _object_mask_projectile, 0);
+ while ((object = object_iterator_next(&iterator)) != NULL)
+ distributed_client_delete(iterator.index);
+ }
+ for (index = 0; index < count; index++)
+ {
+ struct migration_object_state state;
+ struct object_datum *object;
+ long object_index;
+ unsigned long item_flags = 0;
+ memcpy(&state, buffer + sizeof(count) + index * sizeof(state), sizeof(state));
+ object_index = state.identity.object_index;
+ if (state.type == _object_type_projectile)
+ {
+ long absolute = DATUM_INDEX_TO_ABSOLUTE_INDEX(object_index);
+ /* A newer canonical object took this slot after the checkpoint:
+the generation wins. Never delete a surviving player's current object. */
+ struct datum_header const *header = (struct datum_header const *)
+ ((byte const *)object_header_data->data + absolute * object_header_data->size);
+ if (header->identifier)
+ continue;
+ distributed_client_create(&state.identity);
+ }
+ object = object_try_and_get(object_index);
+ if (!object || object->definition_index != state.identity.definition_index || object->object.type != state.type)
+ continue;
+ object->object.shield_damage_decay_timer = state.shield_damage_decay_timer;
+ object->object.body_damage_decay_timer = state.body_damage_decay_timer;
+ object->object.shield_stun_ticks = state.shield_stun_ticks;
+ if (TEST_FLAG(_object_mask_item | _object_mask_projectile, state.type))
+ {
+ item_flags = ((struct item_datum *)object)->item.flags;
+ ((struct item_datum *)object)->item = state.item;
+ }
+ if (state.type == _object_type_weapon)
+ {
+ struct _weapon_datum *weapon = &((struct weapon_datum *)object)->weapon;
+ boolean keep_current = network_objects_migration_weapon_local(object);
+ short magazine;
+ /* Ammo, magazine/reload state and trigger flags are one coherent
+tuple. Combining newer ammo with an old reload can discard a filled clip
+on the next tick. Local prediction, or any newer inventory, wins together. */
+ for (magazine = 0; magazine < 2; magazine++)
+ {
+ if (weapon->magazines[magazine].rounds_total != state.weapon.magazines[magazine].rounds_total ||
+ weapon->magazines[magazine].rounds_loaded != state.weapon.magazines[magazine].rounds_loaded)
+ keep_current = TRUE;
+ }
+ if (keep_current)
+ ((struct item_datum *)object)->item.flags = item_flags;
+ else
+ *weapon = state.weapon;
+ if (weapon->tracked_object_index != NONE && !object_try_and_get(weapon->tracked_object_index))
+ weapon->tracked_object_index = NONE;
+ }
+ if (state.type == _object_type_projectile)
+ {
+ object->object.owner_object_index = state.owner_object_index != NONE &&
+ object_try_and_get(state.owner_object_index) ? state.owner_object_index : NONE;
+ ((struct projectile_datum *)object)->projectile = state.projectile;
+ if (state.projectile.ignore_object_index != NONE && !object_try_and_get(state.projectile.ignore_object_index))
+ ((struct projectile_datum *)object)->projectile.ignore_object_index = NONE;
+ if (state.projectile.target_object_index != NONE && !object_try_and_get(state.projectile.target_object_index))
+ ((struct projectile_datum *)object)->projectile.target_object_index = NONE;
+ if (state.parent_object_index != NONE && object_try_and_get(state.parent_object_index))
+ object_attach_to_node(state.parent_object_index, object_index, state.parent_node_index);
+ }
+ }
+ /* Replay only unconfirmed projectile movement. Applying its damage again
+would duplicate an explosion already reflected in the surviving world. */
+ network_damage_migration_replay(TRUE);
+ for (index = 0; index < count; index++)
+ {
+ struct migration_object_state state;
+ long step;
+ memcpy(&state, buffer + sizeof(count) + index * sizeof(state), sizeof(state));
+ if (state.type != _object_type_projectile)
+ continue;
+ for (step = 0; step < elapsed_ticks; step++)
+ {
+ struct object_header_datum *header = object_header_try_and_get(state.identity.object_index);
+ if (!header || !header->datum || TEST_FLAG(header->flags, _object_header_being_deleted_bit))
+ break;
+ projectile_update(state.identity.object_index);
+ }
+ }
+ network_damage_migration_replay(FALSE);
+ return TRUE;
+}
+
+void network_objects_migration_reset_transport(void)
+{
+ long absolute;
+ /* A promoted host will reannounce the inherited objects. Existing clients
+retain their canonical object generations and can accept those announcements. */
+ for (absolute = 0; absolute < MAXIMUM_TRACKED_OBJECTS; absolute++)
+ objects_host_told[absolute] = NONE;
+ objects_host_resting_cursor = 0;
+ memset(objects_host_inventories, 0, sizeof(objects_host_inventories));
+ memset(objects_host_vehicle_predictions, 0, sizeof(objects_host_vehicle_predictions));
+ if (network_game_distributed_client())
+ {
+ /* The new stream may have missed a spawn or delete. Ask for the
+ existing host's complete object set, without loading a new match. */
+ objects_client_synchronized = FALSE;
+ objects_client_resynchronizing = TRUE;
+ memset(objects_client_resync_seen, 0, sizeof(objects_client_resync_seen));
+ }
+ objects_client_ready_time = NONE;
+ objects_client_creating_index = NONE;
+ objects_client_creating = FALSE;
+ objects_client_deleting = FALSE;
+}
diff --git a/port/linux/game/network_test.c b/port/linux/game/network_test.c
index 504f13dbf3..44eb045240 100644
--- a/port/linux/game/network_test.c
+++ b/port/linux/game/network_test.c
@@ -12,6 +12,8 @@ Automated system link sessions for testing the netcode without the menus
makes it short) the next, as the host's button on the scores does;
- "join" searches for games and joins the first it finds, as picking it in
the system link list does.
+- "observe" only logs the existing game; it never creates a session,
+ adds players or runs the scripted kills, pickups and vehicle actions.
Once the game runs, every second each machine logs where every player's
unit is, so the machines' views of the game can be compared.
@@ -58,6 +60,12 @@ Called from the main loop every frame (main.c).
#include
#include
+#ifdef HALO_WEB
+/* called without a prototype in scope; a WebAssembly call must match the
+definition's signature */
+boolean player_handle_powerup(long player_index, short powerup_type, short duration);
+#endif
+
/* the platform layer's (port/linux/src/port_config.c) */
const char *config_string(char const *name);
double config_real(char const *name);
@@ -77,6 +85,7 @@ enum
_network_test_off,
_network_test_host,
_network_test_join,
+ _network_test_observe,
};
static struct
@@ -107,6 +116,7 @@ static struct
char pickup_weapon[64];
long score_to_win;
long logged_time;
+ long observed_time;
} network_test;
/* the variant at the index of the list (copied to name), FALSE past its end */
@@ -158,6 +168,10 @@ static void network_test_read_settings(
{
network_test.mode = _network_test_join;
}
+ else if (!strcmp(setting, "observe"))
+ {
+ network_test.mode = _network_test_observe;
+ }
network_test.start_delay = (real)config_real("debug.network_test_start");
network_test.kill_interval = (real)config_real("debug.network_test_kill");
network_test.shoot_interval = (real)config_real("debug.network_test_shoot");
@@ -728,6 +742,24 @@ void network_test_update(
network_test_read_settings();
if (network_test.mode == _network_test_off)
return;
+ if (network_test.mode == _network_test_observe)
+ {
+ if (game_in_progress() && !main_menu_loaded)
+ {
+ long observed_time = game_time_get();
+
+ /* Migration can restore an earlier confirmed game tick. Report it
+ immediately instead of waiting for the prediction clock to catch up. */
+ if (observed_time < network_test.observed_time ||
+ observed_time - network_test.logged_time >= TICKS_PER_SECOND)
+ {
+ network_test.logged_time = observed_time;
+ network_test_log_players();
+ }
+ network_test.observed_time = observed_time;
+ }
+ return;
+ }
/* the game running: report (from the start of each game: the next
game's time starts over) */
diff --git a/port/linux/game/quick_play.c b/port/linux/game/quick_play.c
new file mode 100644
index 0000000000..1adf86555d
--- /dev/null
+++ b/port/linux/game/quick_play.c
@@ -0,0 +1,414 @@
+/* Browser quick play uses the normal session and player APIs. Host migration
+adopts the running world on this thread; it never generates player input.
+Cancellation and ordinary match endings leave the menus in control. */
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+
+#ifdef HALO_QUICK_PLAY_TEST
+#include "quick_play_test_stubs.h"
+#else
+#include "cseries.h"
+#include "main/main.h"
+#include "interface/player_ui.h"
+#include "interface/ui_widget.h"
+#include "networking/network_game_globals.h"
+#include "networking/network_client_manager.h"
+#include "networking/network_server_manager.h"
+#include "networking/network_migration.h"
+#include "game/game.h"
+#include "game/game_engine.h"
+#endif
+
+#include
+
+const char *config_string(const char *name);
+int web_quick_play_take_cancel(void);
+int web_quick_play_take_migrate(int *host, unsigned long *target, unsigned int *epoch);
+int web_quick_play_take_reconnect(unsigned long *target, unsigned int *epoch);
+int web_quick_play_take_hold(void);
+unsigned long web_quick_play_initial_epoch(void);
+void web_quick_play_report(const char *phase, const char *message);
+
+enum { QUICK_OFF, QUICK_SETTLING, QUICK_SEARCHING, QUICK_JOINING, QUICK_PREGAME,
+ QUICK_STARTING, QUICK_PLAYING, QUICK_HOLD, QUICK_RECONNECTING, QUICK_MIGRATING, QUICK_BLOCKED, QUICK_DONE };
+
+static struct
+{
+ boolean checked, host, owned, map_set, player_confirmed;
+ short phase;
+ unsigned long target, phase_at, player_at, retry_at, menu_at;
+ boolean menu_seen;
+ boolean was_paused, migration_committed;
+ unsigned int epoch;
+} quick_play;
+
+/* transport_client_start converts the socket's network-order word with
+SWAP4 before writing transport_address. Parse into that engine address order
+(100.86.56.19 -> 0x64563813), not the browser ring/socket order. */
+static boolean quick_play_parse_address(const char *text, unsigned long *address)
+{
+ unsigned int octet;
+ int index;
+ *address = 0;
+ if (!text[0])
+ return TRUE;
+ for (index = 0; index < 4; index++)
+ {
+ int digits = 0;
+ octet = 0;
+ while (*text >= '0' && *text <= '9')
+ {
+ if (++digits > 3)
+ return FALSE;
+ octet = octet * 10 + (unsigned int)(*text++ - '0');
+ }
+ if (!digits || octet > 255 || (!index && (!octet || octet >= 224)))
+ return FALSE;
+ *address = (*address << 8) | octet;
+ if (index < 3 ? *text++ != '.' : *text != 0)
+ return FALSE;
+ }
+ return TRUE;
+}
+
+static void quick_play_phase(short phase, unsigned long now, const char *name, const char *message)
+{
+ quick_play.phase = phase;
+ quick_play.phase_at = now;
+ web_quick_play_report(name, message);
+}
+
+static void quick_play_finish(const char *phase, const char *message, boolean leave)
+{
+ if ((quick_play.phase == QUICK_HOLD || quick_play.phase == QUICK_RECONNECTING || quick_play.phase == QUICK_MIGRATING || quick_play.phase == QUICK_BLOCKED) &&
+ game_time_initialized())
+ game_time_set_paused(quick_play.was_paused);
+ quick_play.phase = QUICK_DONE;
+ if (leave)
+ {
+ if (quick_play.owned && global_network_game_client_get())
+ network_game_abort();
+ main_goto_main_menu();
+ }
+ web_quick_play_report(phase, message);
+}
+
+#if defined(HALO_IOS_BROWSER) || defined(HALO_QUICK_PLAY_TEST)
+/* Called only on the game thread after the menu disposes the old session. */
+void ios_quick_play_reset(void)
+{
+ memset(&quick_play, 0, sizeof(quick_play));
+ quick_play.checked = TRUE;
+}
+void ios_quick_play_begin(int host, unsigned long target, unsigned int epoch)
+{
+ ios_quick_play_reset();
+ quick_play.host = host;
+ quick_play.target = target;
+ quick_play.epoch = epoch;
+ quick_play_phase(QUICK_SETTLING, system_milliseconds(), "loading", "Opening multiplayer...");
+}
+#endif
+
+static void quick_play_show_lobby(void)
+{
+ ui_widgets_close_all();
+ ui_widget_load_by_name_or_tag(
+ "ui\\shell\\main_menu\\multiplayer_type_select\\connected\\pregame\\connected_pregame_screen",
+ NONE, NULL, NONE, NONE, NONE, NONE);
+}
+
+/* SDL's swap wait runs on the game thread, including in manual System Link. */
+int web_multiplayer_active(void)
+{
+ return global_network_game_client_get() &&
+ (game_connection() == _game_connection_network_server ||
+ game_connection() == _game_connection_network_client);
+}
+
+/* The normal host helper initializes its playlist before quick play reaches
+pregame. Select the downloaded map there, rather than precaching Carousel. */
+char const *web_quick_play_initial_map(void)
+{
+ return quick_play.host && quick_play.owned && quick_play.phase == QUICK_SETTLING ?
+ "levels\\test\\beavercreek\\beavercreek" : "";
+}
+
+/* The quick-play host owns every spawn, including late joins and respawns. */
+boolean web_quick_play_pistol_starts(void)
+{
+ return quick_play.host && quick_play.owned && quick_play.map_set &&
+ (quick_play.phase == QUICK_STARTING || quick_play.phase == QUICK_PLAYING);
+}
+
+boolean web_match_migration_enabled(void)
+{
+#ifdef HALO_IOS_BROWSER
+ { int host_ios_browser_mode(void); if(!host_ios_browser_mode())return FALSE; }
+#endif
+ return quick_play.owned && (quick_play.phase == QUICK_PLAYING || quick_play.phase == QUICK_HOLD ||
+ quick_play.phase == QUICK_RECONNECTING || quick_play.phase == QUICK_MIGRATING || quick_play.phase == QUICK_BLOCKED);
+}
+
+boolean web_match_migration_lost(void)
+{
+ if (!web_match_migration_enabled()) return FALSE;
+ if (quick_play.phase == QUICK_PLAYING)
+ {
+ quick_play.was_paused = game_time_get_paused();
+ game_time_set_paused(TRUE);
+ quick_play_phase(QUICK_HOLD, system_milliseconds(), "disconnected",
+ "The host connection was lost. Preserving the match while a replacement is chosen...");
+ }
+ return TRUE;
+}
+
+void quick_play_update(boolean main_menu_loaded)
+{
+ unsigned long now = system_milliseconds();
+ struct network_game_client *client;
+ short state;
+ int migration_host, hold;
+ unsigned long migration_target;
+ unsigned int migration_epoch;
+
+ /* Explicit cancellation wins over any queued election or hold. */
+#ifdef HALO_IOS_BROWSER
+ { void ios_room_pump(void); ios_room_pump(); now = system_milliseconds(); }
+#endif
+ if (web_quick_play_take_cancel() && quick_play.phase != QUICK_OFF)
+ {
+ quick_play_finish("menu", "Returned to the main menu.", TRUE);
+ return;
+ }
+ hold = web_quick_play_take_hold();
+ if (hold == 1) web_match_migration_lost();
+ else if (hold == 2 && quick_play.phase == QUICK_HOLD && global_network_game_client_get() &&
+ !network_game_client_get_error(global_network_game_client_get()))
+ {
+ game_time_set_paused(quick_play.was_paused);
+ quick_play_phase(QUICK_PLAYING, now, "playing", "Multiplayer is ready.");
+ }
+ if (web_quick_play_take_migrate(&migration_host, &migration_target, &migration_epoch) &&
+ web_match_migration_enabled() && migration_epoch > quick_play.epoch)
+ {
+ web_match_migration_lost();
+ quick_play.epoch = migration_epoch;
+ quick_play.target = migration_target;
+ quick_play.host = migration_host;
+ quick_play.migration_committed = FALSE;
+ if ((migration_host && !create_global_network_game_server_from_migration(migration_epoch)) ||
+ !network_game_client_begin_migration(global_network_game_client_get(),
+ migration_host ? 0x7f000001UL : migration_target, migration_epoch))
+ {
+ quick_play_phase(QUICK_BLOCKED, now, "migration-failed",
+ "Host migration could not restore the connection. The match is preserved and paused.");
+ return;
+ }
+ quick_play_phase(QUICK_MIGRATING, now, "migrating", "Reconnecting players to the same match...");
+ }
+ if (web_quick_play_take_reconnect(&migration_target, &migration_epoch) &&
+ web_match_migration_enabled() && !quick_play.host && quick_play.phase != QUICK_MIGRATING &&
+ migration_epoch == quick_play.epoch && migration_target == quick_play.target)
+ {
+ web_match_migration_lost();
+ if (network_game_client_begin_migration(global_network_game_client_get(), migration_target, migration_epoch))
+ quick_play_phase(QUICK_RECONNECTING, now, "reconnecting", "Reconnecting your player to the current host...");
+ }
+
+ if (!quick_play.checked)
+ {
+ const char *mode = config_string("network.quick_play");
+ quick_play.checked = TRUE;
+ quick_play.epoch = (unsigned int)web_quick_play_initial_epoch();
+ if (!mode[0])
+ return;
+ if (strcmp(mode, "host") && strcmp(mode, "join"))
+ {
+ quick_play_finish("error", "Invalid quick-play mode. Use the game menus or reload to try again.", FALSE);
+ return;
+ }
+ quick_play.host = !strcmp(mode, "host");
+ if (!quick_play_parse_address(config_string("network.quick_play_target"), &quick_play.target))
+ {
+ quick_play_finish("error", "Invalid quick-play host address.", FALSE);
+ return;
+ }
+ quick_play_phase(QUICK_SETTLING, now, "loading", "Opening multiplayer...");
+ }
+ if (quick_play.phase == QUICK_HOLD || quick_play.phase == QUICK_BLOCKED)
+ return;
+ if (quick_play.phase == QUICK_MIGRATING || quick_play.phase == QUICK_RECONNECTING)
+ {
+ struct network_game_server *server = global_network_game_server_get();
+ client = global_network_game_client_get();
+ if (quick_play.host && server && !quick_play.migration_committed &&
+ (network_game_server_migration_ready(server) || now - quick_play.phase_at >= 12000UL))
+ {
+ network_game_server_migration_finish(server);
+ quick_play.migration_committed = TRUE;
+ }
+ if (client && network_game_client_migration_ready(client))
+ {
+ game_time_set_paused(quick_play.was_paused);
+ quick_play_phase(QUICK_PLAYING, now, "playing", quick_play.phase == QUICK_RECONNECTING ?
+ "Reconnected to the current host. The match continues." : "The match continues with the replacement host.");
+ }
+ /* A slow transport is still retryable. Keep checking its ACK rather
+ than permanently blocking a preserved match after 45 seconds. */
+ return;
+ }
+ if (quick_play.phase == QUICK_OFF || quick_play.phase == QUICK_DONE)
+ return;
+ if (quick_play.phase == QUICK_SETTLING)
+ {
+ if (now - quick_play.phase_at > 120000UL)
+ {
+ quick_play_finish("error", "The game took too long to open multiplayer.", TRUE);
+ return;
+ }
+ if (!main_menu_loaded)
+ return;
+ if (!quick_play.menu_seen)
+ {
+ quick_play.menu_seen = TRUE;
+ quick_play.menu_at = now;
+ }
+ if (now - quick_play.menu_at < 2000UL)
+ return;
+ quick_play.owned = TRUE;
+ if (quick_play.host)
+ {
+ player_ui_fast_setup_network_server();
+ if (!global_network_game_server_get() || !global_network_game_client_get())
+ {
+ quick_play_finish("error", "Could not create the multiplayer game.", TRUE);
+ return;
+ }
+ quick_play_phase(QUICK_JOINING, now, "hosting", "Creating Beaver Creek Slayer...");
+ }
+ else
+ {
+ dispose_global_network_game_client();
+ dispose_global_network_game_server();
+ if (!create_global_network_game_client())
+ {
+ quick_play_finish("error", "Could not open multiplayer networking.", TRUE);
+ return;
+ }
+ game_connection_set(_game_connection_network_client);
+ quick_play_phase(QUICK_SEARCHING, now, "searching", "Finding the multiplayer game...");
+ }
+ quick_play.retry_at = now;
+ return;
+ }
+
+ client = global_network_game_client_get();
+ if (!client || game_connection() != (quick_play.host ? _game_connection_network_server : _game_connection_network_client))
+ {
+ /* The player or normal engine error handling already left this session. */
+ quick_play_finish(!quick_play.host && quick_play.phase == QUICK_PLAYING ? "disconnected" : "menu",
+ "Multiplayer closed. Use the game menus or reload to play again.", FALSE);
+ return;
+ }
+ if (network_game_client_get_error(client))
+ {
+ /* The host also has a local client connection, including after live
+ adoption. Its failure must not tear down the world before recovery. */
+ if (quick_play.phase == QUICK_PLAYING && web_match_migration_lost()) return;
+ quick_play_finish(!quick_play.host && quick_play.phase == QUICK_PLAYING ? "disconnected" : "error",
+ "The multiplayer connection failed. Use the game menus or reload to try again.", TRUE);
+ return;
+ }
+ state = network_game_client_get_state(client, NULL);
+ if (state == _network_game_client_state_ingame && !main_menu_loaded)
+ {
+ if (quick_play.phase != QUICK_PLAYING)
+ quick_play_phase(QUICK_PLAYING, now, "playing", "Multiplayer is ready.");
+ return;
+ }
+ if (quick_play.phase == QUICK_PLAYING || state == _network_game_client_state_postgame)
+ {
+ quick_play_finish("menu", "The match ended. Use the game menus or reload to play again.", TRUE);
+ return;
+ }
+ if (now - quick_play.phase_at > (quick_play.phase == QUICK_SEARCHING ? 60000UL : 120000UL))
+ {
+ quick_play_finish("error", quick_play.phase == QUICK_SEARCHING ?
+ "No open game was found. The host may have left or the game may be full." :
+ "The multiplayer game did not start in time. Use the game menus or reload to try again.", TRUE);
+ return;
+ }
+ if (quick_play.phase == QUICK_SEARCHING)
+ {
+ if (now - quick_play.retry_at >= 500UL)
+ {
+ short result;
+ quick_play.retry_at = now;
+ result = network_game_client_quick_join(quick_play.target);
+ if (result < 0)
+ {
+ quick_play_finish("error", result == -1 ? "The host uses a different game version." :
+ result == -2 ? "This game is full or no longer accepting players." :
+ "Could not connect to the multiplayer game.", TRUE);
+ }
+ else if (result > 0)
+ {
+ quick_play_show_lobby();
+ quick_play_phase(QUICK_JOINING, now, "joining", "Joining the multiplayer game...");
+ }
+ }
+ return;
+ }
+ if (state == _network_game_client_state_pregame)
+ {
+ if (quick_play.phase == QUICK_JOINING)
+ quick_play_phase(QUICK_PREGAME, now, "waiting", "Adding your player...");
+ if (quick_play.host && !quick_play.map_set)
+ {
+ struct game_variant variant;
+ struct network_game_server *server = global_network_game_server_get();
+ if (!server)
+ {
+ quick_play_finish("error", "The multiplayer host closed.", TRUE);
+ return;
+ }
+ network_game_server_change_map_name(server, "levels\\test\\beavercreek\\beavercreek");
+ variant = *game_engine_get_variant_by_name(&variant, "slayer");
+ player_ui_set_game_variant(&variant);
+ network_game_server_change_game_variant(server, &variant);
+ if (!network_game_server_enable_quick_play(server))
+ {
+ quick_play_finish("error", "Quick play requires the distributed Slayer game mode.", TRUE);
+ return;
+ }
+ quick_play.map_set = TRUE;
+ }
+ if (!quick_play.player_confirmed && network_game_client_has_local_player(client, 0))
+ {
+ quick_play.player_confirmed = TRUE;
+ quick_play.player_at = now;
+ web_quick_play_report("waiting", quick_play.host ? "Starting Beaver Creek Slayer..." : "Waiting for the host to start...");
+ }
+ if (!quick_play.player_confirmed && now - quick_play.retry_at >= 500UL)
+ {
+ quick_play.retry_at = now;
+ network_game_client_add_player(client, 0);
+ }
+ if (quick_play.host && quick_play.player_confirmed && now - quick_play.player_at >= 3000UL &&
+ (quick_play.phase != QUICK_STARTING || (now - quick_play.retry_at >= 1000UL &&
+ network_game_client_get_seconds_to_game_start(client) < 0)))
+ {
+ /* A start request can arrive while lobby changes are still pending.
+ Retry only until the normal countdown acknowledges it; retain the
+ overall deadline, precache checks and remote-player readiness gates. */
+ network_game_server_pause_countdown(global_network_game_server_get(), FALSE);
+ network_game_client_request_immediate_start();
+ quick_play.retry_at = now;
+ if (quick_play.phase != QUICK_STARTING)
+ quick_play_phase(QUICK_STARTING, now, "loading", "Loading Beaver Creek...");
+ }
+ else if (!quick_play.host && quick_play.phase != QUICK_STARTING && network_game_client_server_has_started_game(client))
+ quick_play_phase(QUICK_STARTING, now, "loading", "Loading the multiplayer map...");
+ }
+}
+#endif
diff --git a/port/linux/game/render_interpolation.c b/port/linux/game/render_interpolation.c
index b007f84eae..59a354b496 100644
--- a/port/linux/game/render_interpolation.c
+++ b/port/linux/game/render_interpolation.c
@@ -2,7 +2,7 @@
RENDER_INTERPOLATION.C
Frames between the game's 30 Hz ticks, for the native ports (port/linux,
-port/android, port/windows; see port/linux/README.md, "Frame rate").
+port/ios, port/windows; see port/linux/README.md, "Frame rate").
The game simulates in 30 Hz ticks and originally drew one frame per tick.
The ports draw at the display's refresh rate instead, and every frame shows
@@ -669,7 +669,7 @@ static struct observer_result const *render_interpolation_direct_camera(
short local_player_index,
struct observer_result const *observer)
{
-#ifdef HALO_ANDROID
+#if defined(HALO_ANDROID) || defined(HALO_IOS)
(void)local_player_index;
return observer;
#else
diff --git a/port/linux/include/StdDef.h b/port/linux/include/StdDef.h
index 3465d16218..0fd4c15371 100644
--- a/port/linux/include/StdDef.h
+++ b/port/linux/include/StdDef.h
@@ -1,2 +1,3 @@
-/* the game includes ; Linux file names are case sensitive */
-#include
+/* The game includes . Skip this directory so case-insensitive
+ host filesystems do not resolve back to this shim. */
+#include_next
diff --git a/port/linux/include/halo_linux_prefix.h b/port/linux/include/halo_linux_prefix.h
index dc3660457e..6499c62055 100644
--- a/port/linux/include/halo_linux_prefix.h
+++ b/port/linux/include/halo_linux_prefix.h
@@ -10,7 +10,11 @@ byte-for-byte identical to what the matching MSVC build compiles.
#ifndef __HALO_LINUX_PREFIX_H
#define __HALO_LINUX_PREFIX_H
-#if !defined(__i386__) && !defined(HALO_ANDROID)
+#if defined(HALO_ANDROID) && !defined(HALO_ILP32)
+#define HALO_ILP32 1
+#endif
+
+#if !defined(__i386__) && !defined(HALO_ILP32)
#error the Linux port targets 32-bit x86: game data structures assume 32-bit pointers
#endif
diff --git a/port/linux/include/halo_ui_pointer.h b/port/linux/include/halo_ui_pointer.h
index bc7035a1a7..556ca6c8ea 100644
--- a/port/linux/include/halo_ui_pointer.h
+++ b/port/linux/include/halo_ui_pointer.h
@@ -25,7 +25,7 @@ struct halo_ui_pointer
/* frees the mouse for the menus while menus_active, and captures it again
for aiming when not; while menus are active returns nonzero and what the
-pointer did since the last call. Always 0 on Android. */
+pointer did since the last call. Always 0 on iOS. */
int halo_ui_pointer_update(int menus_active, struct halo_ui_pointer *pointer);
#endif
diff --git a/port/linux/src/d3d8_gl.c b/port/linux/src/d3d8_gl.c
index 43c924d24c..ad85b3bb62 100644
--- a/port/linux/src/d3d8_gl.c
+++ b/port/linux/src/d3d8_gl.c
@@ -25,6 +25,10 @@ Conventions carried over from the Xbox:
#include "sdl_platform.h"
#include "halo_ui_pointer.h"
#include "port_config.h"
+#include "halo_display.h"
+#ifdef HALO_IOS
+#include "guest_host.h"
+#endif
#include
#include
@@ -35,8 +39,8 @@ Conventions carried over from the Xbox:
void d3d8_surface_initialize(D3DSurface *surface, D3DFORMAT format, unsigned long width, unsigned long height);
void d3d8_surface_resize(D3DSurface *surface, D3DFORMAT format, unsigned long width, unsigned long height);
-#ifdef HALO_ANDROID
-/* OpenGL ES 3 (port/android/README.md): the desktop formats, enumerants
+#ifdef HALO_ILP32
+/* OpenGL ES 3 (port/android/README.md and port/ios/README.md): the desktop formats, enumerants
and entry points used below that ES lacks */
#define GL_BGRA GL_RGBA
#define glDepthRange glDepthRangef
@@ -58,7 +62,7 @@ struct xgpu_capabilities xgpu_capabilities;
/* ---------- the screen's width
The Xbox screen is 640x480. The native ports can draw a wider one: 480
-lines, and as many columns as the display's shape gives. On Android that is
+lines, and as many columns as the display's shape gives. On Android/iOS that is
display.screen_width (port_config.c; 640 keeps 4:3); on the desktop, the
display's shape while the game is fullscreen, and 640 in a window. The
game's camera derives its horizontal field of view from the viewport, so the
@@ -66,10 +70,11 @@ game's camera derives its horizontal field of view from the viewport, so the
640 columns; while they draw (halo_screen_ui_offset), everything shifts right
to center them.
-Fullscreen on the desktop also draws at the display's resolution: render
+The native ports also draw at the display's resolution: render
targets the size of the screen get that many pixels (screen_scale), and
viewports, clears and visibility counts are scaled to match, so the game
-still works in its 480 lines. The width and the scale change only between
+still works in its 480 lines. iOS display.render_height can reduce the render
+size; its default of zero uses physical native pixels. The width and scale change only between
frames, after one is presented (halo_screen_commit). */
#define SCREEN_HEIGHT 480
@@ -80,16 +85,26 @@ render target the size of the screen has per unit of it */
static long screen_width;
static float screen_scale[2] = { 1.0f, 1.0f };
static long ui_offset;
+static GLint screen_maximum_texture_size = 8192;
#define UI_OFFSET ((GLint)ui_offset)
static void screen_mode_choose(long *width, float scale[2])
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* display.screen_width, or 0 for the display's shape, which the app
- passes (port/android/host/host_main.c) */
+ passes (the Android/iOS host) */
const char *display = getenv("HALO_DISPLAY_WIDTH");
+#ifdef HALO_IOS
+ const char *pixels_x = getenv("HALO_DISPLAY_PIXEL_WIDTH");
+ const char *pixels_y = getenv("HALO_DISPLAY_PIXEL_HEIGHT");
+#endif
+ long requested_width = config_integer("display.screen_width");
+#ifdef HALO_IOS
+ int drawable_width = 0, drawable_height = 0;
+ struct halo_display_size pixels;
+#endif
- *width = config_integer("display.screen_width");
+ *width = requested_width;
if (*width <= 0)
*width = display ? atol(display) : 640;
if (*width < 640)
@@ -97,7 +112,22 @@ static void screen_mode_choose(long *width, float scale[2])
if (*width > 1600)
*width = 1600;
*width &= ~1L;
+#ifdef HALO_IOS
+ /* The drawable is authoritative once the UIKit window exists. Before
+ then the native host supplies the display's physical pixel dimensions. */
+ platform_video_drawable_size(&drawable_width, &drawable_height);
+ if (drawable_width <= 0 || drawable_height <= 0) {
+ drawable_width = pixels_x ? atoi(pixels_x) : (int)*width;
+ drawable_height = pixels_y ? atoi(pixels_y) : SCREEN_HEIGHT;
+ }
+ pixels = halo_display_render_size(drawable_width, drawable_height, *width,
+ requested_width, host_ios_render_height() ? (long)host_ios_render_height() :
+ config_integer("display.render_height"), screen_maximum_texture_size);
+ scale[0] = (float)pixels.width / (float)*width;
+ scale[1] = (float)pixels.height / (float)SCREEN_HEIGHT;
+#else
scale[0] = scale[1] = 1.0f;
+#endif
#else
long display_width, display_height;
@@ -274,7 +304,7 @@ static struct framebuffer_entry *framebuffers;
/* ---------- the device */
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* Mobile drivers (Mali) keep every orphaned copy of a buffer until the GPU
is done with it, so a large buffer orphaned each frame costs its size per
frame in flight and more. Instead each frame streams into the next of a few
@@ -289,7 +319,7 @@ vertices. */
#define INDEX_BUFFER_SIZE (8 * 1024 * 1024)
#endif
#define VISIBILITY_TEST_SLOTS 4096
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
#define VISIBILITY_QUERY GL_ANY_SAMPLES_PASSED
#define VISIBILITY_ALL_SAMPLES 1000000
#else
@@ -336,7 +366,7 @@ struct gl_device
GLuint vertex_array;
GLuint stream_buffer;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
GLuint stream_buffers[STREAM_BUFFER_RING];
GLuint index_buffers[STREAM_BUFFER_RING];
unsigned long buffer_ring;
@@ -353,7 +383,7 @@ struct gl_device
float query_area[VISIBILITY_TEST_SLOTS];
GLuint active_query;
BOOL visibility_test_active;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* with atomic counters: one counter per test, used as a ring; the
counter a test ended in, per result slot */
GLuint visibility_counters;
@@ -556,6 +586,10 @@ static void state_element_array_buffer(GLuint buffer)
}
}
+#ifdef HALO_WEB
+#include "web_geometry.h"
+#endif
+
static void state_attribute_pointer(GLuint index, GLuint buffer, GLint size, GLenum type, GLboolean normalized,
BOOL integer, GLsizei stride, unsigned long offset)
{
@@ -730,7 +764,7 @@ static GLuint compile_shader(GLenum type, const char *source, const char *what)
return shader;
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
static void GLAPIENTRY gl_debug_callback(GLenum source, GLenum type, GLuint id, GLenum severity,
GLsizei length, const GLchar *message, const void *user)
{
@@ -758,6 +792,9 @@ static void surface_dimensions(const D3DSurface *surface, unsigned long *width,
static struct render_target_entry *render_target_get(const D3DSurface *surface)
{
struct render_target_entry *entry;
+#ifdef HALO_WEB
+ struct render_target_entry *resized = NULL;
+#endif
unsigned long width, height;
BOOL depth;
@@ -775,13 +812,23 @@ static struct render_target_entry *render_target_get(const D3DSurface *surface)
for (entry = *render_target_bucket(surface->Data); entry; entry = entry->next_in_bucket)
{
if (entry->target.data == surface->Data && entry->target.width == width &&
- entry->target.height == height && entry->target.depth == depth &&
- entry->target.scale[0] == scale[0] && entry->target.scale[1] == scale[1])
- {
- return entry;
+ entry->target.height == height && entry->target.depth == depth)
+ {
+ if (entry->target.scale[0] == scale[0] && entry->target.scale[1] == scale[1])
+ return entry;
+#ifdef HALO_WEB
+ /* A browser resize changes the scale, not the guest surface.
+ Reuse its texture (and attached FBOs) instead of retaining a
+ full color/depth allocation for every window size forever. */
+ resized = entry;
+#endif
}
}
+#ifdef HALO_WEB
+ entry = resized ? resized : calloc(1, sizeof(*entry));
+#else
entry = calloc(1, sizeof(*entry));
+#endif
entry->target.data = surface->Data;
entry->target.width = width;
entry->target.height = height;
@@ -790,7 +837,8 @@ static struct render_target_entry *render_target_get(const D3DSurface *surface)
entry->target.scale[1] = scale[1];
entry->target.gl_width = (unsigned long)(width * scale[0] + 0.5f);
entry->target.gl_height = (unsigned long)(height * scale[1] + 0.5f);
- glGenTextures(1, &entry->target.texture);
+ if (!entry->target.texture)
+ glGenTextures(1, &entry->target.texture);
glBindTexture(GL_TEXTURE_2D, entry->target.texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
if (depth)
@@ -800,6 +848,10 @@ static struct render_target_entry *render_target_get(const D3DSurface *surface)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, (GLsizei)entry->target.gl_width, (GLsizei)entry->target.gl_height,
0, GL_BGRA, GL_UNSIGNED_BYTE, NULL);
xgpu_gl_state_invalidate();
+#ifdef HALO_WEB
+ if (resized)
+ return entry;
+#endif
entry->next = render_targets;
render_targets = entry;
entry->next_in_bucket = *render_target_bucket(entry->target.data);
@@ -886,7 +938,15 @@ static void gl_initialize(void)
glGetIntegerv(GL_MAJOR_VERSION, &major);
glGetIntegerv(GL_MINOR_VERSION, &minor);
-#ifdef HALO_ANDROID
+ glGetIntegerv(GL_MAX_TEXTURE_SIZE, &screen_maximum_texture_size);
+#ifdef HALO_IOS
+ /* Select the real Retina drawable before allocating any screen targets. */
+ (void)halo_screen_width();
+ screen_mode_choose(&screen_width, screen_scale);
+ platform_log("iOS render target: %.0fx%.0f (logical %ldx%d)",
+ screen_width * screen_scale[0], SCREEN_HEIGHT * screen_scale[1], screen_width, SCREEN_HEIGHT);
+#endif
+#ifdef HALO_ILP32
{
BOOL es32 = major > 3 || (major == 3 && minor >= 2);
@@ -925,7 +985,19 @@ static void gl_initialize(void)
#endif
glGenVertexArrays(1, &device.vertex_array);
glBindVertexArray(device.vertex_array);
-#ifdef HALO_ANDROID
+#ifdef HALO_WEB
+ {
+ const char *geometry_cache = getenv("HALO_WEB_GEOMETRY_CACHE");
+
+ web_geometry.enabled = geometry_cache && !strcmp(geometry_cache, "1");
+ if (web_geometry.enabled)
+ platform_log("browser geometry cache: rotating uploads and retained vertices enabled");
+ }
+#endif
+#if defined(HALO_ANDROID) || defined(HALO_IOS)
+#ifdef HALO_WEB
+ if (!web_geometry.enabled)
+#endif
{
int ring;
@@ -942,7 +1014,7 @@ static void gl_initialize(void)
device.index_buffer = device.index_buffers[0];
}
#endif
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
glGenBuffers(1, &device.stream_buffer);
glBindBuffer(GL_ARRAY_BUFFER, device.stream_buffer);
glBufferData(GL_ARRAY_BUFFER, STREAM_BUFFER_SIZE, NULL, GL_STREAM_DRAW);
@@ -952,7 +1024,7 @@ static void gl_initialize(void)
#endif
glGenSamplers(D3DTSS_MAXSTAGES, device.samplers);
glGenQueries(VISIBILITY_TEST_SLOTS, device.queries);
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
glGenBuffers(1, &device.visibility_results_buffer);
glBindBuffer(GL_QUERY_BUFFER, device.visibility_results_buffer);
glBufferStorage(GL_QUERY_BUFFER, VISIBILITY_TEST_SLOTS * sizeof(GLuint), NULL,
@@ -974,7 +1046,7 @@ static void gl_initialize(void)
}
}
#endif
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (xgpu_capabilities.atomic_counters)
{
glGenBuffers(1, &device.visibility_counters);
@@ -1087,7 +1159,7 @@ HRESULT WINAPI Direct3D_CreateDevice(UINT adapter, D3DDEVTYPE device_type, void
device.presentation = *presentation_parameters;
width = device.presentation.BackBufferWidth ? device.presentation.BackBufferWidth : 640;
height = device.presentation.BackBufferHeight ? device.presentation.BackBufferHeight : 480;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
d3d8_surface_initialize(&device.back_buffer, D3DFMT_LIN_A8R8G8B8, width, height);
d3d8_surface_initialize(&device.depth_buffer, D3DFMT_LIN_D24S8, width, height);
#else
@@ -1151,7 +1223,7 @@ HRESULT WINAPI Direct3D_CreateDevice(UINT adapter, D3DDEVTYPE device_type, void
/* ---------- the menus' pointer */
-#ifdef HALO_ANDROID
+#if defined(HALO_ILP32) && !defined(HALO_IOS)
int halo_ui_pointer_update(int menus_active, struct halo_ui_pointer *pointer)
{
(void)pointer;
@@ -1227,7 +1299,7 @@ long halo_screen_commit(void)
screen_width = width;
screen_scale[0] = scale[0];
screen_scale[1] = scale[1];
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
if (device.created)
{
device.presentation.BackBufferWidth = (UINT)width;
@@ -1371,7 +1443,7 @@ void WINAPI D3DDevice_BeginVisibilityTest(void)
return;
/* the query object is chosen when the test ends; use a scratch one */
device.visibility_test_active = TRUE;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (xgpu_capabilities.atomic_counters)
{
const GLuint zero = 0;
@@ -1384,6 +1456,11 @@ void WINAPI D3DDevice_BeginVisibilityTest(void)
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
return;
}
+#endif
+#ifdef HALO_WEB
+ /* WebGL answers queries only between tasks, which the game's thread
+ never returns to: every test passes (D3DDevice_GetVisibilityTestResult) */
+ return;
#endif
glBeginQuery(VISIBILITY_QUERY, device.queries[0]);
}
@@ -1398,13 +1475,17 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index)
index %= VISIBILITY_TEST_SLOTS;
if (!index)
index = 1;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (xgpu_capabilities.atomic_counters)
{
device.counter_of_slot[index] = device.counter_active;
device.query_pending[index] = TRUE;
return S_OK;
}
+#endif
+#ifdef HALO_WEB
+ device.query_pending[index] = TRUE;
+ return S_OK;
#endif
glEndQuery(VISIBILITY_QUERY);
/* the target's pixels to a game pixel: the result is a count of the
@@ -1417,7 +1498,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index)
device.queries[0] = device.queries[index];
device.queries[index] = scratch;
device.query_pending[index] = TRUE;
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
if (device.visibility_results)
{
/* the GPU writes the count into the slot once it is known */
@@ -1429,7 +1510,7 @@ HRESULT WINAPI D3DDevice_EndVisibilityTest(DWORD index)
return S_OK;
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
/* a count of pixels in the game's pixels */
static GLuint visibility_unscaled(GLuint samples, DWORD index)
{
@@ -1454,7 +1535,7 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON
*result = 0;
return S_OK;
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (xgpu_capabilities.atomic_counters)
{
/* reading the buffer waits for the draws that counted */
@@ -1465,7 +1546,7 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON
return S_OK;
}
#endif
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
if (device.visibility_results)
{
/* the latest count the GPU has written: from this test, or while
@@ -1474,12 +1555,17 @@ HRESULT WINAPI D3DDevice_GetVisibilityTestResult(DWORD index, UINT *result, ULON
*result = visibility_unscaled(device.visibility_results[index], index);
return S_OK;
}
+#endif
+#ifdef HALO_WEB
+ if (result)
+ *result = VISIBILITY_ALL_SAMPLES;
+ return S_OK;
#endif
glGetQueryObjectuiv(device.queries[index], GL_QUERY_RESULT_AVAILABLE, &available);
if (!available)
return D3DERR_TESTINCOMPLETE;
glGetQueryObjectuiv(device.queries[index], GL_QUERY_RESULT, &samples);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES only says whether any sample passed. The game divides the count by
the test's area (lens flare brightness, rasterizer_lights.c): report
more than any test covers, well below what would overflow there. */
@@ -2001,7 +2087,7 @@ static GLenum address_mode(DWORD mode)
{
case D3DTADDRESS_MIRROR: return GL_MIRRORED_REPEAT;
case D3DTADDRESS_CLAMP: return GL_CLAMP_TO_EDGE;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
case D3DTADDRESS_BORDER: return xgpu_capabilities.border_clamp ? GL_CLAMP_TO_BORDER : GL_CLAMP_TO_EDGE;
#else
case D3DTADDRESS_BORDER: return GL_CLAMP_TO_BORDER;
@@ -2057,7 +2143,7 @@ static void configure_sampler(int stage, BOOL mipmapped, BOOL hires)
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, (GLint)address_mode(state[D3DTSS_ADDRESSU]));
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, (GLint)address_mode(state[D3DTSS_ADDRESSV]));
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_R, (GLint)address_mode(state[D3DTSS_ADDRESSW]));
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES has no sampler LOD bias; the pixel shader applies it
(texture_lod_bias) */
glSamplerParameterf(sampler, GL_TEXTURE_MIN_LOD, (float)maximum_mip_level);
@@ -2096,7 +2182,7 @@ struct mip_composite
static struct mip_composite *mip_composites;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
static GLuint framebuffer_get(GLuint color, GLuint depth);
/* glCopyImageSubData for ES 3.0/3.1 contexts without the extension */
@@ -2161,7 +2247,7 @@ static GLuint mip_composite_get(const struct xgpu_texture_description *descripti
if (!target || target->width != width || target->height != height ||
target->gl_width != width || target->gl_height != height)
break;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (!xgpu_capabilities.copy_image)
{
copy_level_by_blit(target->texture, composite->texture, (GLint)level, (GLsizei)width, (GLsizei)height);
@@ -2391,7 +2477,7 @@ static void apply_raster_state(BOOL has_depth)
state_enable(&gl_state.cull_face, GL_CULL_FACE, rs[D3DRS_CULLMODE] != D3DCULL_NONE);
if (rs[D3DRS_CULLMODE] != D3DCULL_NONE)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* the vertex shader flips y in clip space, which (unlike desktop
GL's upper-left clip origin) also flips the winding */
GLenum front_face = rs[D3DRS_FRONTFACE] == D3DFRONT_CCW ? GL_CW : GL_CCW;
@@ -2411,7 +2497,7 @@ static void apply_raster_state(BOOL has_depth)
glCullFace(cull_mode);
}
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
/* ES draws filled polygons only (wireframe is a debug mode) */
{
GLenum polygon_mode = rs[D3DRS_FILLMODE] == D3DFILL_WIREFRAME ? GL_LINE :
@@ -2427,7 +2513,7 @@ static void apply_raster_state(BOOL has_depth)
/* D3DRS_ZBIAS is expressed in these states (D3DDevice_SetRenderState_ZBias) */
state_enable(&gl_state.offset_fill, GL_POLYGON_OFFSET_FILL, rs[D3DRS_SOLIDOFFSETENABLE] != 0);
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
state_enable(&gl_state.offset_line, GL_POLYGON_OFFSET_LINE, rs[D3DRS_SOLIDOFFSETENABLE] != 0);
#endif
if (rs[D3DRS_SOLIDOFFSETENABLE])
@@ -2444,7 +2530,7 @@ static void apply_raster_state(BOOL has_depth)
}
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES has no debug callback in 3.0; debug.gl_debug polls glGetError around
each draw instead, reporting each distinct error a few times */
static void gl_check_errors(const char *where)
@@ -2502,7 +2588,7 @@ apply to the others. A memory barrier is one (and only that: nothing
here writes images). */
static void draw_flush(void)
{
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
if (device.flush_every && ++device.flush_draws >= device.flush_every)
{
device.flush_draws = 0;
@@ -2542,7 +2628,6 @@ static struct program_entry *prepare_draw(BOOL immediate)
stats.skipped_no_target++;
return NULL;
}
- apply_raster_state(has_depth);
memset(&key, 0, sizeof(key));
memcpy(key.combiner_state, D3D__RenderState, sizeof(key.combiner_state));
@@ -2552,6 +2637,12 @@ static struct program_entry *prepare_draw(BOOL immediate)
key.combiner_state[D3DRS_PSFINALCOMBINERCONSTANT1] = 0;
key.texture_modes = D3D__RenderState[D3DRS_PSTEXTUREMODES];
bind_textures(&key, uniforms.texture_scale);
+ /* ES mipmap copies bind temporary framebuffers and disable scissoring.
+ Restore the destination and raster state AFTER texture preparation,
+ otherwise the water draw lands in the presentation framebuffer. */
+ if (!bind_targets(&has_depth))
+ return NULL;
+ apply_raster_state(has_depth);
for (stage = 0; stage < D3DTSS_MAXSTAGES; stage++)
{
key.alpha_kill[stage] = D3D__TextureState[stage][D3DTSS_ALPHAKILL] == D3DTALPHAKILL_ENABLE;
@@ -2564,7 +2655,7 @@ static struct program_entry *prepare_draw(BOOL immediate)
key.alpha_test_function = D3D__RenderState[D3DRS_ALPHATESTENABLE] ? D3D__RenderState[D3DRS_ALPHAFUNC] : 0;
key.fog_enable = D3D__RenderState[D3DRS_FOGENABLE] != 0;
key.fog_table_mode = (unsigned char)D3D__RenderState[D3DRS_FOGTABLEMODE];
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
key.count_samples = device.visibility_test_active && xgpu_capabilities.atomic_counters;
#endif
@@ -2582,7 +2673,7 @@ static struct program_entry *prepare_draw(BOOL immediate)
stats.draws++;
draw_flush();
state_program(entry->program);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (key.count_samples)
glBindBufferRange(GL_ATOMIC_COUNTER_BUFFER, 0, device.visibility_counters,
(GLintptr)(device.counter_active * sizeof(GLuint)), sizeof(GLuint));
@@ -2947,7 +3038,7 @@ static BOOL mirror_refresh(unsigned long first, unsigned long last)
glBufferData(GL_COPY_WRITE_BUFFER, MIRROR_SEGMENT_SIZE, NULL, GL_DYNAMIC_DRAW);
}
glBindBuffer(GL_COPY_WRITE_BUFFER, mirror.buffers[segment]);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* Mali copies the whole buffer for a glBufferSubData that queued
draws might read (see STREAM_BUFFER_RING); unused pages can be
written without waiting for them */
@@ -2978,6 +3069,12 @@ static BOOL mirror_range(unsigned long address, unsigned long size, GLuint *buff
unsigned long segment, first, last, page, oldest = ~0UL, newest = 0;
BOOL present = TRUE;
+#ifdef HALO_WEB
+ /* The retained cache compares the exact bytes consumed by this draw,
+ including swizzled colours, rather than hashing entire guest pages. */
+ if (web_geometry.enabled)
+ return FALSE;
+#endif
if (!size || address < PLATFORM_CONTIGUOUS_BASE || start + size > PLATFORM_CONTIGUOUS_SIZE)
return FALSE;
segment = start / MIRROR_SEGMENT_SIZE;
@@ -3076,6 +3173,10 @@ between two of them would leave the attributes already pointed at the
buffer reading its new, empty storage. */
static void stream_reserve(unsigned long size)
{
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ return;
+#endif
if (device.stream_offset + size > STREAM_BUFFER_SIZE)
{
/* orphan the buffer and start again */
@@ -3085,15 +3186,23 @@ static void stream_reserve(unsigned long size)
}
}
-static unsigned long stream_upload(const void *data, unsigned long size)
+static unsigned long stream_upload_key(const void *key, const void *data, unsigned long size)
{
unsigned long offset;
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ {
+ device.stream_buffer = web_geometry_vertices(key, data, size);
+ return 0;
+ }
+#endif
+ (void)key;
size = (size + 15) & ~15UL;
stream_reserve(size);
offset = device.stream_offset;
state_array_buffer(device.stream_buffer);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
host_gl_buffer_write(GL_ARRAY_BUFFER, (unsigned int)offset, (unsigned int)size, data);
#else
glBufferSubData(GL_ARRAY_BUFFER, (GLintptr)offset, (GLsizeiptr)size, data);
@@ -3102,7 +3211,12 @@ static unsigned long stream_upload(const void *data, unsigned long size)
return offset;
}
-#ifdef HALO_ANDROID
+static unsigned long stream_upload(const void *data, unsigned long size)
+{
+ return stream_upload_key(data, data, size);
+}
+
+#if defined(HALO_ANDROID) || defined(HALO_IOS)
/* stream_upload, with the D3DCOLOR elements of the stream turned from BGRA
into the RGBA byte order ES reads */
static unsigned long stream_upload_swizzled(const struct vertex_shader_object *declaration, unsigned long stream,
@@ -3140,7 +3254,9 @@ static unsigned long stream_upload_swizzled(const struct vertex_shader_object *d
color[2] = blue;
}
}
- return stream_upload(scratch, size);
+ /* The scratch allocation is shared by all streams. Cache by the source
+ range and compare the converted bytes, not by the scratch address. */
+ return stream_upload_key(data, scratch, size);
}
#endif
@@ -3148,6 +3264,13 @@ static unsigned long index_upload(const void *data, unsigned long size)
{
unsigned long offset;
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ {
+ device.index_buffer = web_geometry_stream(GL_ELEMENT_ARRAY_BUFFER, data, size);
+ return 0;
+ }
+#endif
size = (size + 15) & ~15UL;
state_element_array_buffer(device.index_buffer);
if (device.index_offset + size > INDEX_BUFFER_SIZE)
@@ -3156,7 +3279,7 @@ static unsigned long index_upload(const void *data, unsigned long size)
device.index_offset = 0;
}
offset = device.index_offset;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
host_gl_buffer_write(GL_ELEMENT_ARRAY_BUFFER, (unsigned int)offset, (unsigned int)size, data);
#else
glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, (GLintptr)offset, (GLsizeiptr)size, data);
@@ -3174,7 +3297,7 @@ static void attribute_format(const struct vertex_element *element, GLint *size,
case D3DVSDT_FLOAT2: *size = 2; *type = GL_FLOAT; break;
case D3DVSDT_FLOAT3: case D3DVSDT_FLOAT2H: *size = 3; *type = GL_FLOAT; break;
case D3DVSDT_FLOAT4: *size = 4; *type = GL_FLOAT; break;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES has no BGRA attributes: stream_upload_swizzled swaps the bytes */
case D3DVSDT_D3DCOLOR: *size = 4; *type = GL_UNSIGNED_BYTE; *normalized = GL_TRUE; break;
#else
@@ -3199,7 +3322,7 @@ static void attribute_format(const struct vertex_element *element, GLint *size,
/* upload vertices [first, first + count) of every stream the declaration
uses and point the attributes at them; attribute data then starts at
vertex 0 of the uploaded range */
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES has no BGRA attributes, so a stream with colours is swizzled as it is
uploaded (stream_upload_swizzled) and cannot come from the mirror */
static BOOL stream_has_colors(const struct vertex_shader_object *declaration, unsigned long stream)
@@ -3238,7 +3361,7 @@ static void setup_streams(unsigned long first, unsigned long count)
placed[stream] = TRUE;
stream_buffers[stream] = 0;
base = (unsigned long)PLATFORM_PHYSICAL_TO_VIRTUAL(device.streams[stream].data) + first * stride;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (!stream_has_colors(declaration, stream))
#endif
if (mirror_range(base, bytes, &stream_buffers[stream], &stream_offsets[stream], NULL))
@@ -3263,7 +3386,7 @@ static void setup_streams(unsigned long first, unsigned long count)
const unsigned char *base = PLATFORM_PHYSICAL_TO_VIRTUAL(device.streams[stream].data);
unsigned long bytes = stride ? stride * count : 64;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
stream_offsets[stream] = stream_upload_swizzled(declaration, stream, base + first * stride, bytes, stride);
#else
stream_offsets[stream] = stream_upload(base + first * stride, bytes);
@@ -3350,6 +3473,10 @@ void WINAPI D3DDevice_DrawVertices(D3DPRIMITIVETYPE primitive_type, UINT start_v
{
if (!vertex_count || !prepare_draw(FALSE))
return;
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ web_geometry_begin_draw(device.frame);
+#endif
trace_draw("draw", primitive_type, vertex_count, NULL);
setup_streams(start_vertex, vertex_count);
if (primitive_type == D3DPT_QUADLIST)
@@ -3378,9 +3505,13 @@ void WINAPI D3DDevice_DrawIndexedVertices(D3DPRIMITIVETYPE primitive_type, UINT
if (!vertex_count || !index_data || !prepare_draw(FALSE))
return;
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ web_geometry_begin_draw(device.frame);
+#endif
/* quads are drawn as triangles, from indices made for the draw */
mirrored = primitive_type != D3DPT_QUADLIST &&
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
xgpu_capabilities.base_vertex &&
#endif
mirror_range((unsigned long)index_data, vertex_count * sizeof(WORD), &index_buffer, &index_offset, &generation);
@@ -3403,7 +3534,7 @@ void WINAPI D3DDevice_DrawIndexedVertices(D3DPRIMITIVETYPE primitive_type, UINT
indices = quad_indices(index_data, vertex_count, &count);
source = indices;
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (!xgpu_capabilities.base_vertex)
{
/* the indices are copied anyway: rebase them */
@@ -3456,13 +3587,45 @@ void WINAPI D3DDevice_End(void)
device.immediate_active = FALSE;
if (!count || !prepare_draw(TRUE))
return;
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ web_geometry_begin_draw(device.frame);
+#endif
trace_draw("immediate", type, count, device.immediate_vertices);
+#ifdef HALO_WEB
+ /* WebGL allows strides of at most 255 bytes, less than a whole immediate
+ vertex: each attribute goes up as an array of its own */
+ {
+ unsigned long attribute_bytes = count * 4 * sizeof(float);
+ float *arrays = malloc(XGPU_VERTEX_ATTRIBUTE_COUNT * attribute_bytes);
+ unsigned long vertex;
+
+ if (!arrays)
+ return;
+ for (index = 0; index < XGPU_VERTEX_ATTRIBUTE_COUNT; index++)
+ {
+ for (vertex = 0; vertex < count; vertex++)
+ {
+ memcpy(arrays + (index * count + vertex) * 4,
+ device.immediate_vertices + vertex * XGPU_VERTEX_ATTRIBUTE_COUNT * 4 + index * 4, 4 * sizeof(float));
+ }
+ }
+ offset = stream_upload(arrays, XGPU_VERTEX_ATTRIBUTE_COUNT * attribute_bytes);
+ free(arrays);
+ for (index = 0; index < XGPU_VERTEX_ATTRIBUTE_COUNT; index++)
+ {
+ state_attribute_pointer(index, device.stream_buffer, 4, GL_FLOAT, GL_FALSE, FALSE,
+ (GLsizei)(4 * sizeof(float)), offset + index * attribute_bytes);
+ }
+ }
+#else
offset = stream_upload(device.immediate_vertices, count * stride);
for (index = 0; index < XGPU_VERTEX_ATTRIBUTE_COUNT; index++)
{
state_attribute_pointer(index, device.stream_buffer, 4, GL_FLOAT, GL_FALSE, FALSE, (GLsizei)stride,
offset + index * 4 * sizeof(float));
}
+#endif
if (type == D3DPT_QUADLIST)
{
unsigned long index_count;
@@ -3629,7 +3792,7 @@ static void write_screenshot(struct render_target_entry *target)
image viewers would show it as transparency */
for (row = 0; row < width * height; row++)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
unsigned char red = pixels[row * 4];
pixels[row * 4] = pixels[row * 4 + 2];
@@ -3691,26 +3854,37 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat
}
x = (window_width - width) / 2;
y = (window_height - height) / 2;
- glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
- glDisable(GL_SCISSOR_TEST);
- glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
- glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
- glClear(GL_COLOR_BUFFER_BIT);
- glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer_get(back_buffer->target.texture, 0));
- /* row 0 of the render target is the top of the picture */
- glBlitFramebuffer(0, 0, (GLint)back_buffer->target.gl_width, (GLint)back_buffer->target.gl_height,
- x, y + height, x + width, y, GL_COLOR_BUFFER_BIT, GL_LINEAR);
+#ifdef HALO_IOS
+ if (!host_ios_metalfx_present(back_buffer->target.texture,
+ back_buffer->target.gl_width, back_buffer->target.gl_height, width, height))
+#endif
+ {
+ glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
+ glDisable(GL_SCISSOR_TEST);
+ glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
+ glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glBindFramebuffer(GL_READ_FRAMEBUFFER, framebuffer_get(back_buffer->target.texture, 0));
+ /* row 0 of the render target is the top of the picture */
+ glBlitFramebuffer(0, 0, (GLint)back_buffer->target.gl_width, (GLint)back_buffer->target.gl_height,
+ x, y + height, x + width, y, GL_COLOR_BUFFER_BIT, GL_LINEAR);
+ }
platform_video_swap();
xgpu_gl_state_invalidate();
xgpu_texture_cache_begin_frame();
-#ifdef HALO_ANDROID
- host_gl_fence_frame((unsigned int)device.buffer_ring);
- device.buffer_ring = (device.buffer_ring + 1) % STREAM_BUFFER_RING;
- host_gl_wait_frame((unsigned int)device.buffer_ring);
- device.stream_buffer = device.stream_buffers[device.buffer_ring];
- device.index_buffer = device.index_buffers[device.buffer_ring];
- device.stream_offset = 0;
- device.index_offset = 0;
+#if defined(HALO_ANDROID) || defined(HALO_IOS)
+#ifdef HALO_WEB
+ if (!web_geometry.enabled)
+#endif
+ {
+ host_gl_fence_frame((unsigned int)device.buffer_ring);
+ device.buffer_ring = (device.buffer_ring + 1) % STREAM_BUFFER_RING;
+ host_gl_wait_frame((unsigned int)device.buffer_ring);
+ device.stream_buffer = device.stream_buffers[device.buffer_ring];
+ device.index_buffer = device.index_buffers[device.buffer_ring];
+ device.stream_offset = 0;
+ device.index_offset = 0;
+ }
#else
device.stream_offset = STREAM_BUFFER_SIZE; /* orphan next frame */
device.index_offset = INDEX_BUFFER_SIZE;
@@ -3720,6 +3894,20 @@ void WINAPI D3DDevice_Present(CONST RECT *source_rectangle, CONST RECT *destinat
stats.presents++;
if (debug_settings.statistics && device.frame % 60 == 0)
{
+#ifdef HALO_WEB
+ if (web_geometry.enabled)
+ {
+ platform_log("browser geometry: %lu hits, %lu misses, %lu promotions, %lu invalidations; "
+ "%lu KB uploaded, %lu KB avoided, %lu KB compared; %lu KB retained, "
+ "%lu KB stream storage (%lu KB peak), %lu buffers trimmed",
+ web_geometry.stats.hits, web_geometry.stats.misses, web_geometry.stats.promotions,
+ web_geometry.stats.invalidations, web_geometry.stats.uploaded_bytes / 1024,
+ web_geometry.stats.avoided_bytes / 1024, web_geometry.stats.comparisons / 1024,
+ web_geometry.snapshot_bytes / 1024, web_geometry.stream_bytes / 1024,
+ web_geometry.stats.peak_stream_bytes / 1024, web_geometry.stats.trimmed_buffers);
+ memset(&web_geometry.stats, 0, sizeof(web_geometry.stats));
+ }
+#endif
platform_log("frame %lu: %lu draws, %lu immediate, %lu clears, %lu target changes; skipped %lu no program, %lu no target, %lu link; "
"%lu KB mirrored, %lu KB streamed",
device.frame, stats.draws / stats.presents, stats.immediate_draws / stats.presents, stats.clears / stats.presents,
diff --git a/port/linux/src/gl.h b/port/linux/src/gl.h
index 64bb8057ee..d498e025dd 100644
--- a/port/linux/src/gl.h
+++ b/port/linux/src/gl.h
@@ -16,7 +16,7 @@ Windows it is __stdcall too, and SDL would include windows.h without it) */
#ifndef _WIN32
#undef APIENTRY
#endif
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
#include
#include
#define GLAPIENTRY GL_APIENTRY
@@ -25,10 +25,11 @@ Windows it is __stdcall too, and SDL would include windows.h without it) */
#endif
#pragma pop_macro("APIENTRY")
-#ifdef HALO_ANDROID
-/* OpenGL ES 3.2 (port/android/README.md); tools/android_gl_stubs.py reads
+#ifdef HALO_ILP32
+/* OpenGL ES 3.2 (port/ios/README.md); tools/guest_gl_stubs.py reads
this list to generate the guest's entry points */
/* ANDROID_GL_FUNCTIONS_BEGIN */
+/* GLES_FUNCTIONS_BEGIN */
#define GL_FUNCTIONS(X) \
X(glGetString) \
X(glGetIntegerv) \
@@ -129,6 +130,7 @@ this list to generate the guest's entry points */
X(glBeginQuery) \
X(glEndQuery) \
X(glGetQueryObjectuiv)
+/* GLES_FUNCTIONS_END */
/* ANDROID_GL_FUNCTIONS_END */
#else
#define GL_FUNCTIONS(X) \
@@ -245,7 +247,7 @@ GL_FUNCTIONS(GL_DECLARE_FUNCTION)
/* call sites use the ordinary names; gl_functions.c, which defines the
pointers, sees the declarations without these aliases */
#ifndef GL_FUNCTIONS_DEFINE
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
#define glGetString halo_glGetString
#define glGetIntegerv halo_glGetIntegerv
#define glCopyImageSubData halo_glCopyImageSubData
diff --git a/port/linux/src/halo_display.h b/port/linux/src/halo_display.h
new file mode 100644
index 0000000000..25e9b7a065
--- /dev/null
+++ b/port/linux/src/halo_display.h
@@ -0,0 +1,34 @@
+/* Physical render size, independent of the Xbox's 480-line layout space. */
+#ifndef HALO_DISPLAY_H
+#define HALO_DISPLAY_H
+#include
+
+struct halo_display_size { long width, height; };
+
+static inline struct halo_display_size halo_display_render_size(
+ long display_width, long display_height, long logical_width,
+ long requested_width, long requested_height, long maximum_dimension)
+{
+ struct halo_display_size size;
+ double ratio, height;
+ if (display_width <= 0 || display_height <= 0) {
+ display_width = logical_width;
+ display_height = 480;
+ }
+ /* Auto uses the physical display's exact ratio, not the rounded number
+ of logical columns. Explicit screen_width keeps its chosen aspect. */
+ ratio = requested_width <= 0 ? (double)display_width / display_height :
+ (double)logical_width / 480.0;
+ if (ratio < 4.0 / 3.0) ratio = 4.0 / 3.0;
+ if (ratio > 1600.0 / 480.0) ratio = 1600.0 / 480.0;
+ height = fmin((double)display_height, (double)display_width / ratio);
+ if (requested_height > 0) height = fmin(height, fmax(240.0, (double)requested_height));
+ if (maximum_dimension <= 0) maximum_dimension = 8192;
+ height = fmin(height, fmin((double)maximum_dimension, maximum_dimension / ratio));
+ size.width = (long)floor(height * ratio + 0.5);
+ size.height = (long)floor(height + 0.5);
+ if (size.width < 1) size.width = 1;
+ if (size.height < 1) size.height = 1;
+ return size;
+}
+#endif
diff --git a/port/linux/src/menu_files.c b/port/linux/src/menu_files.c
index 09ebcc99ab..4cb17a4ae3 100644
--- a/port/linux/src/menu_files.c
+++ b/port/linux/src/menu_files.c
@@ -83,7 +83,7 @@ static void file_add(const char *path, const unsigned char *data, unsigned long
static unsigned char *file_read(const char *path, unsigned long *size)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
FILE *file = fopen(path, "rb");
unsigned char *data = NULL;
long length;
@@ -149,7 +149,7 @@ static void files_gather(void)
file_add(embedded->path, (const unsigned char *)embedded->data, embedded->size, 0);
}
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
{
/* (the folder's own and its folders': SDL's * does not cross a /) */
static const char *const patterns[] = { "*.xml", "*/*.xml" };
@@ -355,7 +355,9 @@ static int for_this_platform(struct reader *reader, const XML_Char **attributes)
reader_error(reader, "platform=\"%s\" is not \"desktop\" or \"android\"", platform);
return 1;
}
-#ifdef HALO_ANDROID
+#ifdef HALO_IOS
+ return !strcmp(platform, getenv("HALO_DESKTOP_INPUT") ? "desktop" : "android");
+#elif defined(HALO_ANDROID)
return !strcmp(platform, "android");
#else
return !strcmp(platform, "desktop");
diff --git a/port/linux/src/msvc_crt.c b/port/linux/src/msvc_crt.c
index afeaddac64..8db857f407 100644
--- a/port/linux/src/msvc_crt.c
+++ b/port/linux/src/msvc_crt.c
@@ -295,7 +295,33 @@ static unsigned short msvc_to_control_word(unsigned int value, unsigned short wo
return word;
}
-#ifdef HALO_ANDROID
+#ifdef HALO_WEB
+/* WebAssembly always rounds to nearest and has no exception flags: the MSVC
+control word is only remembered */
+static unsigned int msvc_control_word = CW_DEFAULT;
+
+unsigned int _control87(unsigned int new_value, unsigned int mask)
+{
+ if (mask)
+ msvc_control_word = (msvc_control_word & ~mask) | (new_value & mask);
+ return msvc_control_word;
+}
+
+unsigned int _controlfp(unsigned int new_value, unsigned int mask)
+{
+ return _control87(new_value, mask & ~_EM_DENORMAL);
+}
+
+unsigned int _statusfp(void)
+{
+ return 0;
+}
+
+unsigned int _clearfp(void)
+{
+ return 0;
+}
+#elif defined(HALO_ANDROID) || defined(HALO_IOS)
/* AArch64: the rounding mode lives in FPCR.RMode, the sticky exception
flags in FPSR. Precision control and exception unmasking have no
equivalent; the rest of the MSVC control word is only remembered. */
diff --git a/port/linux/src/nv2a_psh.c b/port/linux/src/nv2a_psh.c
index bbea0de6f4..fa08764171 100644
--- a/port/linux/src/nv2a_psh.c
+++ b/port/linux/src/nv2a_psh.c
@@ -314,7 +314,7 @@ static void dot_input(struct xgpu_text *text, const DWORD *state, int stage)
}
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES samplers have no LOD bias: pass D3DTSS_MIPMAPLODBIAS to the lookup */
#define SAMPLE_BIAS ", texture_lod_bias[%d]"
#define SHADER_VERSION \
@@ -334,21 +334,21 @@ static void sample(struct xgpu_text *text, const struct nv2a_pixel_shader_key *k
{
case _xgpu_sampler_3d:
xgpu_text_append(text, "texture(tex%d, (%s).xyz" SAMPLE_BIAS ")", stage, coordinates
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
, stage
#endif
);
break;
case _xgpu_sampler_cube:
xgpu_text_append(text, "texture(tex%d, (%s).xyz" SAMPLE_BIAS ")", stage, coordinates
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
, stage
#endif
);
break;
default:
xgpu_text_append(text, "texture(tex%d, (%s).xy * texture_scale[%d].xy" SAMPLE_BIAS ")", stage, coordinates, stage
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
, stage
#endif
);
@@ -530,7 +530,7 @@ char *nv2a_pixel_shader_to_glsl(const struct nv2a_pixel_shader_key *key)
if (combiner_count > 8)
combiner_count = 8;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
xgpu_text_append(&text, "#version %s\n", xgpu_capabilities.shading_language);
if (key->count_samples)
{
@@ -655,7 +655,7 @@ char *nv2a_pixel_shader_to_glsl(const struct nv2a_pixel_shader_key *key)
xgpu_text_append(&text, "\tresult = vec4(t0.rgb, 1.0);\n");
if (config_boolean("debug.gpu_debug_flat"))
xgpu_text_append(&text, "\tresult = xD0.a > 0.0 ? vec4(xD0.rgb, 1.0) : vec4(1.0, 0.0, 1.0, 1.0);\n");
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (key->count_samples)
xgpu_text_append(&text, "\tatomicCounterIncrement(visible_samples);\n");
#endif
diff --git a/port/linux/src/nv2a_vsh.c b/port/linux/src/nv2a_vsh.c
index bfb1a7b16f..f977add454 100644
--- a/port/linux/src/nv2a_vsh.c
+++ b/port/linux/src/nv2a_vsh.c
@@ -140,7 +140,7 @@ static void operand(struct xgpu_text *text, const DWORD *instruction, char which
}
static const char shader_prologue[] =
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* the #version line comes first, from the context's capabilities */
"precision highp float;\n"
"precision highp int;\n"
@@ -200,7 +200,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
struct xgpu_text text = { 0 };
unsigned long index;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
xgpu_text_append(&text, "#version %s\n", xgpu_capabilities.shading_language);
#endif
xgpu_text_append(&text, "%s", shader_prologue);
@@ -232,7 +232,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
"\tvec4 oFog = vec4(1.0), oPts = vec4(point_size), oUnused = vec4(0.0);\n"
"\tint a0 = 0;\n"
"\tvec4 A, B, C, mac, ilu;\n");
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
xgpu_text_append(&text, "\tvec4 clip_position = vec4(0.0);\n\tbool clip_captured = false;\n");
#endif
@@ -286,7 +286,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
case _ilu_lit: xgpu_text_append(&text, "\tilu = nv2a_lit(C);\n"); break;
default: xgpu_text_append(&text, "\tilu = vec4(0.0);\n"); break;
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* the screen-space conversion takes the reciprocal of the clip-space
position's w (rcc of r12.w); keep the position it converts */
if (ilu == _ilu_rcc && field(instruction, 3, 28, 2) == _mux_temporary &&
@@ -340,7 +340,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
/* Direct3D 8 puts pixel centres on integer screen coordinates (the
game offsets its screen-space quads by -0.5 to match), OpenGL on
half-integers */
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* The conversion is screen = clip * c[-38] * rcc(w) + c[-37]; undoing
it by multiplying by w again is lossy near the camera plane, where
rcc clamps and 1/w rounds differently on each GPU (Mali put vertices
@@ -355,7 +355,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
"\tvec3 ndc = (vec3(oPos.xy + vec2(0.5 + screen_offset, 0.5), oPos.z) - viewport_offset.xyz) / scale;\n"
"\tgl_Position = vec4(ndc * oPos.w, oPos.w);\n"
#endif
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* what glClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE) does on desktop
GL: rows from the top, depth 0..1 */
"\tgl_Position.y = -gl_Position.y;\n"
@@ -372,7 +372,7 @@ char *nv2a_vertex_shader_to_glsl(const DWORD *instructions, unsigned long instru
"\txT3 = oT3;\n"
"\txFog = oFog.x;\n"
"}\n"
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
, XGPU_VERTEX_CONSTANT_BIAS - 38, XGPU_VERTEX_CONSTANT_BIAS - 37
#endif
);
diff --git a/port/linux/src/p2p.c b/port/linux/src/p2p.c
index 12ddd604c1..c872644627 100644
--- a/port/linux/src/p2p.c
+++ b/port/linux/src/p2p.c
@@ -53,7 +53,11 @@ whatever may wait: DNS, the desktop's link handlers); the game's threads
only look up and create stand-ins.
*/
+#ifdef HALO_RELAY
+#include "relay_platform.h"
+#else
#include "platform.h"
+#endif
#include "posix.h"
#include "port_config.h"
#include "p2p_internal.h"
@@ -892,6 +896,12 @@ static void add_candidates(struct peer *peer, const struct p2p_candidate *candid
{
int known;
+#ifdef HALO_RELAY
+ /* An internet-facing relay must not punch into its private network. */
+ if (!relay_candidate_allowed(candidates[index].address, candidates[index].port))
+ continue;
+#endif
+
for (known = 0; known < peer->candidate_count; known++)
{
if (peer->candidates[known].address == candidates[index].address &&
@@ -3035,7 +3045,7 @@ void p2p_initialize(unsigned long local_address)
posix_socket_setsockopt(p2p.tunnel_socket, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));
posix_socket_setsockopt(p2p.tunnel_socket, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_RELAY)
/* the first copy of the game takes the invites later ones are opened
with */
p2p.has_handoff_key = handoff_key(p2p.handoff_key);
@@ -3051,6 +3061,8 @@ void p2p_initialize(unsigned long local_address)
}
pthread_detach(thread);
p2p.running = 1;
+#ifndef HALO_RELAY
if (command_line_invite(invite, sizeof(invite)))
p2p_join_invite(invite);
+#endif
}
diff --git a/port/linux/src/p2p_crypto.c b/port/linux/src/p2p_crypto.c
index cc5f42d937..0b28f4acbe 100644
--- a/port/linux/src/p2p_crypto.c
+++ b/port/linux/src/p2p_crypto.c
@@ -13,7 +13,11 @@ brokers carry sealed messages, so only holders of the invite read them; the
tunnel's packets are sealed with keys only its two machines have.
*/
+#ifdef HALO_RELAY
+#include "relay_platform.h"
+#else
#include "platform.h"
+#endif
#include "posix.h"
#include "p2p_internal.h"
diff --git a/port/linux/src/p2p_lobby.c b/port/linux/src/p2p_lobby.c
index 0aa4a4af3d..1887688728 100644
--- a/port/linux/src/p2p_lobby.c
+++ b/port/linux/src/p2p_lobby.c
@@ -43,7 +43,11 @@ VERIFY_BUDGET milliseconds of it each pass of the p2p thread, from a queue
of MAXIMUM_QUEUED; the rest is dropped (hosts publish again).
*/
+#ifdef HALO_RELAY
+#include "relay_platform.h"
+#else
#include "platform.h"
+#endif
#include "posix.h"
#include "port_config.h"
#include "p2p_internal.h"
diff --git a/port/linux/src/p2p_signal.c b/port/linux/src/p2p_signal.c
index 0f3a7bf37f..ff88d96429 100644
--- a/port/linux/src/p2p_signal.c
+++ b/port/linux/src/p2p_signal.c
@@ -67,7 +67,11 @@ the host ends before the tunnel reached it, or when the host has not
answered it in a while.
*/
+#ifdef HALO_RELAY
+#include "relay_platform.h"
+#else
#include "platform.h"
+#endif
#include "posix.h"
#include "port_config.h"
#include "p2p_internal.h"
diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c
index 477c2e3fec..d3f333e823 100644
--- a/port/linux/src/port_config.c
+++ b/port/linux/src/port_config.c
@@ -47,8 +47,8 @@ enum config_environment
enum
{
_platform_desktop = 1,
- _platform_android = 2,
- _platform_all = _platform_desktop | _platform_android,
+ _platform_mobile = 2,
+ _platform_all = _platform_desktop | _platform_mobile,
};
struct config_setting
@@ -63,6 +63,14 @@ struct config_setting
const char *comment;
};
+#ifdef HALO_IOS
+#define DEFAULT_INTERNET_PLAY "false"
+#define DEFAULT_CLIPBOARD_JOIN "false"
+#else
+#define DEFAULT_INTERNET_PLAY "true"
+#define DEFAULT_CLIPBOARD_JOIN "true"
+#endif
+
static const struct config_setting config_settings[] =
{
{ "display.fullscreen", _config_boolean, "true", "HALO_FULLSCREEN", _environment_value, _platform_desktop,
@@ -75,9 +83,15 @@ static const struct config_setting config_settings[] =
"display.fullscreen's (true: borderless). F11 switches to the window and back." },
{ "display.window_scale", _config_integer, "2", "HALO_WINDOW_SCALE", _environment_value, _platform_desktop,
"The window's size as a multiple of 640x480 (it can be resized)." },
- { "display.screen_width", _config_integer, "0", "HALO_SCREEN_WIDTH", _environment_value, _platform_android,
+ { "display.screen_width", _config_integer, "0", "HALO_SCREEN_WIDTH", _environment_value, _platform_mobile,
"Columns of the 480-line picture: 0 for the display's shape, 640 for the\n"
"Xbox's 4:3." },
+#ifdef HALO_IOS
+ { "display.render_height", _config_integer, "0", "HALO_RENDER_HEIGHT", _environment_value, _platform_mobile,
+ "Internal rendering height in physical pixels: 0 (default) uses native\n"
+ "display resolution. Try 1080, 720 or 480 for lower GPU/battery use.\n"
+ "The selected aspect ratio is preserved; changes apply on relaunch." },
+#endif
{ "display.vsync", _config_boolean, "true", "HALO_NO_VSYNC", _environment_set_is_false, _platform_all,
"Wait for the display between frames; false draws as fast as possible." },
{ "display.max_fps", _config_integer, "0", "HALO_MAX_FPS", _environment_value, _platform_desktop,
@@ -133,8 +147,9 @@ static const struct config_setting config_settings[] =
"The volume of every other sound (effects and speech), 0.0 to 1.0 (of\n"
"audio.volume)." },
- { "input.mouse_sensitivity", _config_real, "1.0", "HALO_MOUSE_SENSITIVITY", _environment_value, _platform_desktop,
- "How far the view turns for the mouse's movement." },
+ { "input.mouse_sensitivity", _config_real, "0.1", "HALO_MOUSE_SENSITIVITY", _environment_value, _platform_desktop,
+ "Mouse turn multiplier. The menu shows 1-100 (value squared / 4000);\n"
+ "the default 0.1 is 20/100." },
{ "input.invert_mouse", _config_boolean, "false", "HALO_MOUSE_INVERT", _environment_set_is_true, _platform_desktop,
"Moving the mouse forward looks down." },
{ "input.mouse_aim_assist", _config_boolean, "false", "HALO_MOUSE_AIM_ASSIST", _environment_value, _platform_desktop,
@@ -213,16 +228,23 @@ static const struct config_setting config_settings[] =
{ "network.address", _config_string, "\"\"", "HALO_NET_ADDRESS", _environment_value, _platform_all,
"This machine's IPv4 address for system link, for a machine on several\n"
"networks; empty chooses one." },
+#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER)
+ { "network.quick_play", _config_string, "\"\"", "HALO_QUICK_PLAY", _environment_value, _platform_all,
+ "Browser quick play: host starts Beaver Creek Slayer; join finds the chosen host.\n"
+ "Empty keeps the normal game menus. This runs only once per launch." },
+ { "network.quick_play_target", _config_string, "\"\"", "HALO_QUICK_PLAY_TARGET", _environment_value, _platform_all,
+ "Optional IPv4 address of the quick-play host; empty joins the first open game." },
+#endif
{ "network.broadcast", _config_string, "\"\"", "HALO_NET_BROADCAST", _environment_value, _platform_all,
"Comma-separated IPv4 addresses system link sends its announcements to\n"
"instead of the local network's broadcast address (for VPNs); empty for\n"
"the local network." },
- { "network.online", _config_boolean, "true", "HALO_NET_ONLINE", _environment_value, _platform_all,
+ { "network.online", _config_boolean, DEFAULT_INTERNET_PLAY, "HALO_NET_ONLINE", _environment_value, _platform_all,
"Internet play: hosting makes an invite link (logged, and put on the\n"
"clipboard) that lets whoever has it join over the internet; opening a\n"
"link (or copying one before switching to the game) joins. Only people\n"
"with the invite can join. Off keeps system link to the local network." },
- { "network.join_from_clipboard", _config_boolean, "true", "HALO_NET_JOIN_FROM_CLIPBOARD", _environment_value,
+ { "network.join_from_clipboard", _config_boolean, DEFAULT_CLIPBOARD_JOIN, "HALO_NET_JOIN_FROM_CLIPBOARD", _environment_value,
_platform_all,
"Join the game of an invite link found on the clipboard when the game\n"
"comes to the front." },
@@ -346,15 +368,15 @@ static const struct config_setting config_settings[] =
"Log texture uploads." },
{ "debug.texture_no_cache", _config_boolean, "false", "HALO_TEXTURE_NO_CACHE", _environment_set_is_true, _platform_all,
"Upload textures again every time they are used." },
- { "debug.sample_seconds", _config_real, "0.0", "HALO_SAMPLE", _environment_value, _platform_android,
+ { "debug.sample_seconds", _config_real, "0.0", "HALO_SAMPLE", _environment_value, _platform_mobile,
"Log where every game thread is this often, in seconds (read by the\n"
"app, port/android/host/host_debug.c); 0 never." },
};
#define NUMBER_OF_CONFIG_SETTINGS (sizeof(config_settings) / sizeof(config_settings[0]))
-#ifdef HALO_ANDROID
-#define CONFIG_PLATFORM _platform_android
+#ifdef HALO_ILP32
+#define CONFIG_PLATFORM _platform_mobile
#else
#define CONFIG_PLATFORM _platform_desktop
#endif
@@ -375,10 +397,10 @@ static pthread_mutex_t config_lock = PTHREAD_MUTEX_INITIALIZER;
static void config_path(char *path, size_t size)
{
-#ifdef HALO_ANDROID
- /* the data folder, which the app names (port/android/host/host_main.c) */
- const char *root = getenv("HALO_DATA_ROOT");
-
+#ifdef HALO_ILP32
+ /* the data folder, which the app names (port/ios/host/host_main.m) */
+ const char *root = getenv("HALO_CONFIG_ROOT");
+ if (!root || !*root) root = getenv("HALO_DATA_ROOT");
snprintf(path, size, "%s/config.toml", root && *root ? root : ".");
#else
/* the executable's folder, with its separator */
@@ -391,7 +413,7 @@ static void config_path(char *path, size_t size)
/* the whole file, NUL terminated, or NULL; free() it */
static char *config_read_file(const char *path, size_t *size)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
FILE *file = fopen(path, "rb");
char *text = NULL;
long length;
@@ -434,7 +456,7 @@ static char *config_read_file(const char *path, size_t *size)
static int config_write_file(const char *path, const char *text)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
FILE *file = fopen(path, "wb");
int written;
@@ -503,8 +525,8 @@ static void config_append_setting(struct config_text *text, const struct config_
if (*line)
line++;
}
-#ifndef HALO_ANDROID
- /* (Android apps have no environment to set) */
+#ifndef HALO_ILP32
+ /* (iOS apps have no environment to set) */
switch (setting->environment_style)
{
case _environment_value:
@@ -530,7 +552,7 @@ static char *config_default_text(void)
char section[32] = "";
size_t index;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
config_append(&text,
"# Halo settings\n"
"#\n"
diff --git a/port/linux/src/port_config.h b/port/linux/src/port_config.h
index 14213a5d3d..452bf226cb 100644
--- a/port/linux/src/port_config.h
+++ b/port/linux/src/port_config.h
@@ -3,9 +3,9 @@ PORT_CONFIG.H
The native ports' settings, read from config.toml (port_config.c): next to
the executable on the desktop, in the data folder (the one holding maps/)
-on Android. A missing file is written with the defaults. Each setting can
+on Android/iOS. A missing file is written with the defaults. Each setting can
also be set for one run with its HALO_* environment variable, which wins
-over the file (the tools and the Android app pass settings that way).
+over the file (the tools and the Android/iOS apps pass settings that way).
Settings are named "section.key", as in the file: "display.vsync".
*/
diff --git a/port/linux/src/posix.h b/port/linux/src/posix.h
index 5b07bd7650..584cde21f3 100644
--- a/port/linux/src/posix.h
+++ b/port/linux/src/posix.h
@@ -14,9 +14,9 @@ made only of 32-bit members so both sides agree on the layout.
#ifndef __HALO_LINUX_POSIX_H
#define __HALO_LINUX_POSIX_H
-/* 32-bit on both sides of the boundary. The Android port compiles these
+/* 32-bit on both sides of the boundary. The Android/iOS ports compiles these
files into its 64-bit host, where long is 64-bit, and calls them from its
-ILP32 guest (port/android/README.md). */
+ILP32 guest (port/android/README.md and port/ios/README.md). */
#ifdef __LP64__
typedef int posix_long;
typedef unsigned int posix_ulong;
@@ -145,7 +145,7 @@ void posix_upnp_stop_forwarding_udp(unsigned short external_port);
/* ---------- the process and the desktop (internet play, p2p.c) */
/* copies the command line argument at index (0 is the program) into buffer;
-returns 0 if there is none (always, on Android) */
+returns 0 if there is none (always, on Android/iOS) */
int posix_command_line_argument(int index, char *buffer, posix_ulong size);
posix_ulong posix_process_id(void);
/* registers this executable as the desktop's handler of links with this
@@ -158,7 +158,7 @@ there is none (always, on Android) */
int posix_user_secret(unsigned char *secret, int size);
/* a connection to the Discord desktop client's local socket or pipe, or -1
-if none is running (always, on Android) */
+if none is running (always, on Android/iOS) */
int posix_discord_connect(void);
/* writes what it can of buffer without waiting; returns the bytes written
(0 if none could be now), or -1 if the connection failed */
diff --git a/port/linux/src/posix_net.c b/port/linux/src/posix_net.c
index a5e8f0ea89..fc12b54285 100644
--- a/port/linux/src/posix_net.c
+++ b/port/linux/src/posix_net.c
@@ -20,7 +20,9 @@ with the host ABI.
#include
#include
#include
+#ifndef __APPLE__
#include
+#endif
#include
#include
#include
@@ -75,6 +77,8 @@ with the host ABI.
#define WINSOCK_SO_ERROR 0x1007
#define WINSOCK_SO_TYPE 0x1008
+#ifndef HALO_WEB
+/* (the web build's sockets are port/web/src/web_net.c) */
static __thread int last_error;
static int fail(void)
@@ -469,7 +473,7 @@ posix_ulong posix_local_ipv4_address(void)
posix_ulong result = 0;
int probe;
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(HALO_IOS) || defined(HALO_WEB)
/* a phone's default route may be its mobile data (on Wi-Fi without the
internet, or sharing its connection), which the local network cannot
reach: first the local network's interface (Wi-Fi, or the one it
@@ -499,6 +503,7 @@ posix_ulong posix_local_ipv4_address(void)
that is up, running and not loopback */
return result ? result : interface_address(0);
}
+#endif
void posix_random_bytes(void *buffer, posix_ulong size)
{
@@ -542,6 +547,7 @@ void posix_random_bytes(void *buffer, posix_ulong size)
}
}
+#ifndef HALO_WEB
posix_ulong posix_resolve_ipv4(const char *host)
{
struct addrinfo hints, *results;
@@ -558,11 +564,13 @@ posix_ulong posix_resolve_ipv4(const char *host)
return address;
}
+#endif
+
/* ---------- the process and the desktop */
int posix_command_line_argument(int index, char *buffer, posix_ulong size)
{
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(HALO_IOS) || defined(HALO_WEB)
(void)index;
(void)buffer;
(void)size;
@@ -599,7 +607,7 @@ posix_ulong posix_process_id(void)
int posix_user_secret(unsigned char *secret, int size)
{
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(HALO_IOS) || defined(HALO_WEB)
(void)secret;
(void)size;
return 0;
@@ -658,7 +666,7 @@ int posix_user_secret(unsigned char *secret, int size)
#endif
}
-#ifndef __ANDROID__
+#if !defined(__ANDROID__) && !defined(HALO_IOS) && !defined(HALO_WEB)
/* runs a program with its arguments and waits for it; its exit status, or -1 */
static int run_program(char *const arguments[])
{
@@ -676,7 +684,7 @@ static int run_program(char *const arguments[])
int posix_register_url_scheme(const char *scheme, const char *description)
{
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(HALO_IOS) || defined(HALO_WEB)
(void)scheme;
(void)description;
return 0;
@@ -740,7 +748,7 @@ int posix_register_url_scheme(const char *scheme, const char *description)
int posix_discord_connect(void)
{
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(HALO_IOS) || defined(HALO_WEB)
return -1;
#else
/* where Discord (and its Flatpak and Snap packages) put discord-ipc-N */
diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c
index e7f7e10283..39ac6a7dac 100644
--- a/port/linux/src/sdl_platform.c
+++ b/port/linux/src/sdl_platform.c
@@ -31,12 +31,12 @@ static BOOL platform_sdl_started = FALSE;
static struct platform_input_state input_state;
/* keys pressed since the last read, so a press and release between two
-reads still counts as a press (input injected on Android, or a slow frame) */
+reads still counts as a press (input injected on Android/iOS, or a slow frame) */
static unsigned char keys_pressed[SDL_SCANCODE_COUNT];
/* likewise the mouse buttons pressed since the last read, so that a click
quicker than a frame still counts */
static unsigned char mouse_buttons_pressed[PLATFORM_MOUSE_BUTTON_COUNT];
-#ifndef HALO_ANDROID
+#if !defined(HALO_ILP32) || defined(HALO_IOS)
/* the menus' pointer (platform_ui_pointer_set_active), under input_lock */
static struct platform_ui_pointer ui_pointer;
static float ui_pointer_wheel;
@@ -79,7 +79,7 @@ static long scoreboard_pages;
static struct platform_keystroke keystroke_queue[KEYSTROKE_QUEUE_SIZE];
static unsigned long keystroke_head, keystroke_count;
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
/* updater.c's: the desktop self-updater */
void updater_start(void);
void updater_poll(SDL_Window *window);
@@ -100,11 +100,13 @@ BOOL platform_sdl_initialize(void)
if (p2p_hand_off_invite())
exit(EXIT_SUCCESS);
SDL_SetHint(SDL_HINT_APP_NAME, "Halo");
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* landscape only; the back key arrives as a key event (xinput_sdl.c)
instead of closing the activity */
SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight");
+#ifdef HALO_ANDROID
SDL_SetHint(SDL_HINT_ANDROID_TRAP_BACK_BUTTON, "1");
+#endif
/* touching the screen must not aim or fire (the mouse drives the
controller emulation in xinput_sdl.c) */
SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
@@ -115,7 +117,7 @@ BOOL platform_sdl_initialize(void)
return FALSE;
}
platform_sdl_started = TRUE;
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
/* found (or offered to the player, platform_offer_game_data) before the
game's window opens */
platform_data_root();
@@ -125,7 +127,7 @@ BOOL platform_sdl_initialize(void)
return TRUE;
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
/* ---------- first start without game data (xbox_files.c) */
struct data_extraction
@@ -348,7 +350,7 @@ int halo_interpolation_enabled(void)
return enabled;
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
/* the display mode (display.mode, else display.fullscreen's: borderless or
the window) */
enum
@@ -435,7 +437,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
scale = 1;
platform_window_scale = scale;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
@@ -449,7 +451,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
if (config_boolean("debug.gl_debug"))
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_DEBUG_FLAG);
-#if !defined(HALO_ANDROID) && !defined(_WIN32)
+#if !defined(HALO_ILP32) && !defined(_WIN32)
/* Mesa's GL thread: the renderer makes thousands of GL calls a frame
and never waits for their results, so handing them to a thread of
their own takes a fifth of the main thread's time off it. It leaves an
@@ -457,9 +459,13 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
setenv("mesa_glthread", "true", 0);
#endif
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
platform_window = SDL_CreateWindow("Halo", (int)(width * scale), (int)(height * scale),
- SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN);
+ SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN
+#ifdef HALO_IOS
+ | SDL_WINDOW_HIGH_PIXEL_DENSITY
+#endif
+ );
#else
/* fullscreen at the desktop's resolution unless display.fullscreen is
false, where the game draws the display's shape at its resolution
@@ -475,11 +481,11 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
platform_log("SDL_CreateWindow failed: %s", SDL_GetError());
return FALSE;
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
platform_fullscreen_kind_apply();
#endif
platform_gl_context = SDL_GL_CreateContext(platform_window);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES 3.2 where the driver has it, otherwise the renderer makes do with
3.0 plus extensions */
if (!platform_gl_context)
@@ -500,7 +506,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
(void)version;
platform_event_thread = SDL_GetCurrentThreadID();
platform_log("OpenGL %s on %s", (const char *)glGetString(GL_VERSION), (const char *)glGetString(GL_RENDERER));
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
platform_mouse_capture(TRUE);
#endif
return TRUE;
@@ -510,7 +516,7 @@ BOOL platform_video_initialize(unsigned long width, unsigned long height)
be resized) and display.vsync, as Settings has written them */
void platform_display_apply(void)
{
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
BOOL fullscreen = platform_fullscreen_setting();
long scale = config_integer("display.window_scale");
@@ -536,7 +542,7 @@ void platform_video_drawable_size(int *width, int *height)
SDL_GetWindowSizeInPixels(platform_window, width, height);
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
/* with vsync off, the time between frames display.max_fps asks for (0:
twice the display's refresh rate), or 0 for no limit. A GPU never left idle
can hang (Intel's Raptor Lake graphics, whose reset then takes the desktop
@@ -570,13 +576,13 @@ static Uint64 frame_interval_ns(void)
#endif
void platform_video_swap(void)
{
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
static Uint64 next_frame;
Uint64 interval, now;
#endif
SDL_GL_SwapWindow(platform_window);
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
interval = frame_interval_ns();
if (!interval)
return;
@@ -743,6 +749,7 @@ BOOL platform_next_keystroke(struct platform_keystroke *keystroke)
/* ---------- internet play's invite links (p2p.c) */
+
#ifdef HALO_ANDROID
/* SDL declares it for Android builds only, which the guest is not
(guest/runtime/guest_sdl.c passes it to the host) */
@@ -861,7 +868,7 @@ static void platform_show_pending_message(void)
pthread_mutex_unlock(&platform_message_lock);
if (!pending)
return;
-#ifdef HALO_ANDROID
+#if defined(HALO_ANDROID) || defined(HALO_IOS)
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_WARNING, title, text, NULL);
#else
{
@@ -884,7 +891,10 @@ menus' Quit: port/linux/game/menu_functions.c); Android's menus have none,
as the system closes its apps */
void platform_request_quit(void)
{
-#ifndef HALO_ANDROID
+#ifdef HALO_IOS
+ void host_apple_request_quit(void);
+ host_apple_request_quit();
+#elif !defined(HALO_ANDROID)
SDL_Event event;
memset(&event, 0, sizeof(event));
@@ -937,7 +947,7 @@ void platform_pump_events(void)
exit(EXIT_SUCCESS);
}
platform_show_pending_message();
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
updater_poll(platform_window);
#endif
pthread_mutex_lock(&input_lock);
@@ -979,7 +989,7 @@ void platform_pump_events(void)
input_state.mouse_released = !input_state.mouse_released;
platform_mouse_capture(!input_state.mouse_released && !input_state.ui_pointer);
}
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
/* F11 switches between fullscreen and the window (SDL keeps the
window's size and place while fullscreen) */
if (event.key.down && !event.key.repeat && event.key.scancode == SDL_SCANCODE_F11)
@@ -990,7 +1000,7 @@ void platform_pump_events(void)
#endif
break;
case SDL_EVENT_MOUSE_MOTION:
-#ifndef HALO_ANDROID
+#if !defined(HALO_ILP32) || defined(HALO_IOS)
/* in the menus the mouse moves the pointer, not the view */
if (input_state.ui_pointer)
{
@@ -1021,7 +1031,7 @@ void platform_pump_events(void)
binding_captured_input = INPUT_MOUSE + event.button.button;
break;
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ILP32) || defined(HALO_IOS)
/* clicks in the menus go to the pointer; a button held down
when the menu closes stays up until pressed again, so the click
that resumes the game does not also fire */
@@ -1072,7 +1082,7 @@ void platform_pump_events(void)
}
break;
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
if (input_state.ui_pointer)
{
/* whole notches: smooth-scrolling wheels send fractions */
@@ -1101,7 +1111,7 @@ void platform_pump_events(void)
case SDL_EVENT_WINDOW_FOCUS_GAINED:
input_state.focused = TRUE;
look_at_clipboard = TRUE;
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
if (!input_state.mouse_released && !input_state.ui_pointer)
platform_mouse_capture(TRUE);
#endif
@@ -1150,7 +1160,7 @@ int platform_binding_capture_poll(int *input)
return result;
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ILP32) || defined(HALO_IOS)
/* ---------- the menus' pointer */
/* While a menu is up the mouse is released, its pointer shows (centered when
@@ -1158,6 +1168,9 @@ the menu opens) and its motion, clicks and wheel go to the menus
(halo_ui_pointer_update, d3d8_gl.c) instead of the controller and the aim. */
void platform_ui_pointer_set_active(BOOL active)
{
+#ifdef HALO_IOS
+ if (!getenv("HALO_DESKTOP_INPUT")) return;
+#endif
if (!platform_window || (active != FALSE) == (input_state.ui_pointer != FALSE))
return;
pthread_mutex_lock(&input_lock);
diff --git a/port/linux/src/sdl_platform.h b/port/linux/src/sdl_platform.h
index 6ecd3b3609..001f204f1d 100644
--- a/port/linux/src/sdl_platform.h
+++ b/port/linux/src/sdl_platform.h
@@ -59,7 +59,7 @@ struct platform_keystroke
BOOL platform_sdl_initialize(void);
/* creates the window and makes its OpenGL context current on this thread */
BOOL platform_video_initialize(unsigned long width, unsigned long height);
-#ifndef HALO_ANDROID
+#ifndef HALO_ILP32
BOOL platform_screen_mode(long *width, long *height);
#endif
void platform_video_drawable_size(int *width, int *height);
@@ -76,7 +76,7 @@ void platform_mouse_capture(BOOL capture);
void platform_pump_events(void);
/* a snapshot of the input state; consume_motion resets the mouse deltas */
void platform_input_read(struct platform_input_state *state, BOOL consume_motion);
-#ifndef HALO_ANDROID
+#if !defined(HALO_ILP32) || defined(HALO_IOS)
/* the pointer in the menus (d3d8_gl.c, halo_ui_pointer_update) */
struct platform_ui_pointer
{
diff --git a/port/linux/src/updater.c b/port/linux/src/updater.c
index 09c13e1a97..1b389ecbf0 100644
--- a/port/linux/src/updater.c
+++ b/port/linux/src/updater.c
@@ -29,7 +29,7 @@ update.h's: posix_update.c on Linux, win32_update.c on Windows.
#include "port_config.h"
#include "update.h"
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
#include "memory/zlib/zlib.h"
diff --git a/port/linux/src/web_geometry.h b/port/linux/src/web_geometry.h
new file mode 100644
index 0000000000..6927afd069
--- /dev/null
+++ b/port/linux/src/web_geometry.h
@@ -0,0 +1,214 @@
+/* Browser-only geometry uploads. Included after the GL binding cache.
+
+ Small stream buffers rotate through three frame generations. Each upload
+ owns a slot until its draw is submitted; wrapping a busy generation may
+ orphan an earlier draw's buffer, never another stream of the current draw.
+
+ Stable vertices use a bounded retained buffer cache. WebAssembly has no
+ write faults, so pointer identity alone cannot establish immutability:
+ compare every uploaded byte, including any colour conversion. Promote
+ only after a second identical observation; constantly changing data stays
+ in the stream pool. No code or binary from another renderer is included. */
+#ifndef HALO_WEB_GEOMETRY_H
+#define HALO_WEB_GEOMETRY_H
+
+#include
+
+#define WEB_GEOMETRY_FRAMES 3
+#define WEB_GEOMETRY_SLOTS 512
+/* Target for retained stream storage, excluding copies a driver keeps for
+submitted draws. One unusually large draw can exceed it: its own streams
+must stay alive until submission, and later draws trim the excess. */
+#define WEB_GEOMETRY_STREAM_BYTES (32UL * 1024 * 1024)
+#define WEB_GEOMETRY_CACHE_BUCKETS 128
+#define WEB_GEOMETRY_CACHE_WAYS 4
+#define WEB_GEOMETRY_CACHE_BYTES (8UL * 1024 * 1024)
+#define WEB_GEOMETRY_CACHE_ENTRY_BYTES (256UL * 1024)
+
+struct web_geometry_slot
+{
+ GLuint buffer;
+ unsigned long size;
+ uint64_t draw;
+};
+
+struct web_geometry_entry
+{
+ const void *key;
+ unsigned char *snapshot;
+ unsigned long size;
+ GLuint buffer;
+ BOOL ready;
+ uint64_t last_use, pinned_draw;
+};
+
+static struct
+{
+ BOOL enabled;
+ unsigned long frame, next[2], snapshot_bytes, stream_bytes, trim_cursor;
+ uint64_t draw;
+ struct web_geometry_slot slots[WEB_GEOMETRY_FRAMES][2][WEB_GEOMETRY_SLOTS];
+ struct web_geometry_entry entries[WEB_GEOMETRY_CACHE_BUCKETS][WEB_GEOMETRY_CACHE_WAYS];
+ struct
+ {
+ unsigned long hits, misses, promotions, invalidations;
+ unsigned long uploaded_bytes, avoided_bytes, comparisons;
+ unsigned long trimmed_buffers, peak_stream_bytes;
+ } stats;
+} web_geometry;
+
+static void web_geometry_trim_streams(const struct web_geometry_slot *keep, unsigned long size)
+{
+ unsigned long attempts;
+
+ for (attempts = 0; attempts < WEB_GEOMETRY_FRAMES * 2 * WEB_GEOMETRY_SLOTS &&
+ web_geometry.stream_bytes - keep->size + size > WEB_GEOMETRY_STREAM_BYTES; attempts++)
+ {
+ unsigned long index = web_geometry.trim_cursor++ % (WEB_GEOMETRY_FRAMES * 2 * WEB_GEOMETRY_SLOTS);
+ unsigned long frame = index / (2 * WEB_GEOMETRY_SLOTS);
+ unsigned long kind = (index / WEB_GEOMETRY_SLOTS) % 2;
+ struct web_geometry_slot *slot = &web_geometry.slots[frame][kind][index % WEB_GEOMETRY_SLOTS];
+
+ if (!slot->size || slot == keep || slot->draw == web_geometry.draw)
+ continue;
+ if (kind)
+ state_element_array_buffer(slot->buffer);
+ else
+ state_array_buffer(slot->buffer);
+ glBufferData(kind ? GL_ELEMENT_ARRAY_BUFFER : GL_ARRAY_BUFFER, 0, NULL, GL_STREAM_DRAW);
+ web_geometry.stream_bytes -= slot->size;
+ slot->size = 0;
+ web_geometry.stats.trimmed_buffers++;
+ }
+}
+
+static void web_geometry_begin_draw(unsigned long frame)
+{
+ if (web_geometry.frame != frame)
+ {
+ web_geometry.frame = frame;
+ web_geometry.next[0] = web_geometry.next[1] = 0;
+ }
+ web_geometry.draw++;
+}
+
+static GLuint web_geometry_stream(GLenum target, const void *data, unsigned long size)
+{
+ unsigned int kind = target == GL_ELEMENT_ARRAY_BUFFER;
+ struct web_geometry_slot *slot;
+ unsigned long attempts;
+
+ for (attempts = 0; attempts < WEB_GEOMETRY_SLOTS; attempts++)
+ {
+ slot = &web_geometry.slots[web_geometry.frame % WEB_GEOMETRY_FRAMES][kind]
+ [web_geometry.next[kind]++ % WEB_GEOMETRY_SLOTS];
+ if (slot->draw != web_geometry.draw)
+ break;
+ }
+ /* A draw has at most 16 vertex streams and one index stream. */
+ if (attempts == WEB_GEOMETRY_SLOTS)
+ return 0;
+ if (!slot->buffer)
+ glGenBuffers(1, &slot->buffer);
+ slot->draw = web_geometry.draw;
+ web_geometry_trim_streams(slot, size);
+ if (kind)
+ state_element_array_buffer(slot->buffer);
+ else
+ state_array_buffer(slot->buffer);
+ /* Exact payload size: do not read padding beyond a rebased index array
+ or an immediate vertex allocation. BufferData orphans prior draw data. */
+ glBufferData(target, (GLsizeiptr)size, data, GL_STREAM_DRAW);
+ web_geometry.stream_bytes = web_geometry.stream_bytes - slot->size + size;
+ slot->size = size;
+ if (web_geometry.stream_bytes > web_geometry.stats.peak_stream_bytes)
+ web_geometry.stats.peak_stream_bytes = web_geometry.stream_bytes;
+ web_geometry.stats.uploaded_bytes += size;
+ return slot->buffer;
+}
+
+static GLuint web_geometry_vertices(const void *key, const void *data, unsigned long size)
+{
+ unsigned long hash = (((uintptr_t)key >> 4) ^ ((uintptr_t)key >> 15) ^ size) % WEB_GEOMETRY_CACHE_BUCKETS;
+ struct web_geometry_entry *ways = web_geometry.entries[hash], *entry = NULL, *candidate = NULL;
+ unsigned long way;
+
+ if (!size || size > WEB_GEOMETRY_CACHE_ENTRY_BYTES)
+ return web_geometry_stream(GL_ARRAY_BUFFER, data, size);
+ for (way = 0; way < WEB_GEOMETRY_CACHE_WAYS; way++)
+ {
+ if (ways[way].key == key && ways[way].size == size)
+ {
+ entry = &ways[way];
+ break;
+ }
+ if (ways[way].pinned_draw != web_geometry.draw &&
+ (!candidate || ways[way].last_use < candidate->last_use))
+ candidate = &ways[way];
+ }
+ if (entry)
+ {
+ entry->last_use = web_geometry.draw;
+ web_geometry.stats.comparisons += size;
+ if (!memcmp(entry->snapshot, data, size))
+ {
+ if (!entry->ready)
+ {
+ if (!entry->buffer)
+ glGenBuffers(1, &entry->buffer);
+ state_array_buffer(entry->buffer);
+ glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)size, data, GL_STATIC_DRAW);
+ entry->ready = TRUE;
+ web_geometry.stats.promotions++;
+ web_geometry.stats.uploaded_bytes += size;
+ }
+ else
+ {
+ web_geometry.stats.hits++;
+ web_geometry.stats.avoided_bytes += size;
+ }
+ entry->pinned_draw = web_geometry.draw;
+ return entry->buffer;
+ }
+ /* A source can be used with two different colour conversions by
+ two streams in one draw. Keep the first stream's retained data alive. */
+ if (entry->pinned_draw == web_geometry.draw)
+ entry = NULL;
+ else
+ web_geometry.stats.invalidations++;
+ }
+ else
+ {
+ entry = candidate;
+ }
+ web_geometry.stats.misses++;
+ if (entry && web_geometry.snapshot_bytes - entry->size + size <= WEB_GEOMETRY_CACHE_BYTES)
+ {
+ unsigned char *snapshot = entry->snapshot;
+
+ if (entry->size != size)
+ snapshot = malloc(size);
+ if (snapshot)
+ {
+ /* Empty an old retained allocation before giving the entry a
+ new source. Its earlier submitted draws retain the old storage. */
+ if (entry->ready)
+ {
+ state_array_buffer(entry->buffer);
+ glBufferData(GL_ARRAY_BUFFER, 0, NULL, GL_STATIC_DRAW);
+ }
+ if (snapshot != entry->snapshot)
+ free(entry->snapshot);
+ web_geometry.snapshot_bytes = web_geometry.snapshot_bytes - entry->size + size;
+ entry->key = key;
+ entry->snapshot = snapshot;
+ entry->size = size;
+ entry->ready = FALSE;
+ entry->last_use = web_geometry.draw;
+ memcpy(snapshot, data, size);
+ }
+ }
+ return web_geometry_stream(GL_ARRAY_BUFFER, data, size);
+}
+
+#endif
diff --git a/port/linux/src/xbox_files.c b/port/linux/src/xbox_files.c
index bde7c732a8..d9371c88cf 100644
--- a/port/linux/src/xbox_files.c
+++ b/port/linux/src/xbox_files.c
@@ -108,7 +108,7 @@ const char *platform_data_root(void)
snprintf(root, sizeof(root), "%s", executable);
}
}
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
if (!has_maps(root) && executable_directory[0] && platform_offer_game_data(executable_directory) &&
has_maps(executable_directory))
{
diff --git a/port/linux/src/xbox_kernel.c b/port/linux/src/xbox_kernel.c
index 04fc3267a1..c3bb29be9c 100644
--- a/port/linux/src/xbox_kernel.c
+++ b/port/linux/src/xbox_kernel.c
@@ -646,6 +646,20 @@ DWORD WINAPI GetTickCount(void)
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
+#ifdef HALO_WEB
+ /* a browser's monotonic clock counts from 1970: count from the start,
+ as an Xbox counts from its boot (the network code compares tick counts
+ as signed longs, which a count past 2^31 turns negative) */
+ {
+ static unsigned long long start;
+ unsigned long long milliseconds = (unsigned long long)now.tv_sec * 1000ULL +
+ (unsigned long long)now.tv_nsec / 1000000ULL;
+
+ if (!start)
+ start = milliseconds;
+ return (DWORD)(milliseconds - start + 60000ULL);
+ }
+#endif
return (DWORD)((unsigned long long)now.tv_sec * 1000ULL + (unsigned long long)now.tv_nsec / 1000000ULL);
}
@@ -661,6 +675,16 @@ BOOL WINAPI QueryPerformanceCounter(LARGE_INTEGER *count)
clock_gettime(CLOCK_MONOTONIC, &now);
count->QuadPart = (LONGLONG)((unsigned long long)now.tv_sec * PLATFORM_PERFORMANCE_FREQUENCY +
(unsigned long long)now.tv_nsec / (1000000000ULL / PLATFORM_PERFORMANCE_FREQUENCY));
+#ifdef HALO_WEB
+ /* (from the start, as GetTickCount) */
+ {
+ static LONGLONG start;
+
+ if (!start)
+ start = count->QuadPart - 60 * (LONGLONG)PLATFORM_PERFORMANCE_FREQUENCY;
+ count->QuadPart -= start;
+ }
+#endif
return TRUE;
}
diff --git a/port/linux/src/xbox_memory.c b/port/linux/src/xbox_memory.c
index 7ece346d51..c363dee71b 100644
--- a/port/linux/src/xbox_memory.c
+++ b/port/linux/src/xbox_memory.c
@@ -48,6 +48,16 @@ static int protection_to_host(DWORD protect)
__attribute__((constructor(101)))
static void contiguous_arena_reserve(void)
{
+#ifdef HALO_WEB
+ /* the window is the top of the WebAssembly memory, which the C heap
+ stays below (port/web/src/web_main.c) */
+ if ((unsigned long long)__builtin_wasm_memory_size(0) * 65536ULL >=
+ (unsigned long long)PLATFORM_CONTIGUOUS_BASE + PLATFORM_CONTIGUOUS_SIZE)
+ arena_reserved = TRUE;
+ else
+ platform_log("the WebAssembly memory does not reach the Xbox contiguous memory window");
+ return;
+#endif
void *wanted = (void *)PLATFORM_CONTIGUOUS_BASE;
void *result = mmap(wanted, PLATFORM_CONTIGUOUS_SIZE, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0);
@@ -140,6 +150,10 @@ void *platform_contiguous_alloc(unsigned long size, unsigned long alignment,
address = (void *)(PLATFORM_CONTIGUOUS_BASE + first * PAGE_SIZE_BYTES);
memory_watch_forget(address, count * PAGE_SIZE_BYTES);
+#ifdef HALO_WEB
+ /* fresh zeroed pages (WebAssembly memory cannot be remapped) */
+ memset(address, 0, count * PAGE_SIZE_BYTES);
+#else
/* map fresh zeroed pages over the reservation */
if (mmap(address, count * PAGE_SIZE_BYTES, protection_to_host(protect),
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) != address)
@@ -147,6 +161,7 @@ void *platform_contiguous_alloc(unsigned long size, unsigned long alignment,
pthread_mutex_unlock(&arena_lock);
return NULL;
}
+#endif
for (page = first; page < first + count; page++)
page_protection[page] = protect;
block_page_count[first] = count;
@@ -166,8 +181,10 @@ void platform_contiguous_free(void *address)
if (count)
{
memory_watch_forget(address, count * PAGE_SIZE_BYTES);
+#ifndef HALO_WEB
mmap(address, count * PAGE_SIZE_BYTES, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0);
+#endif
for (page = first; page < first + count; page++)
page_protection[page] = 0;
block_page_count[first] = 0;
@@ -209,7 +226,12 @@ BOOL WINAPI VirtualProtect(LPVOID address, SIZE_T size, DWORD new_protect, PDWOR
*old_protect = platform_is_contiguous(address) ?
page_protection[(start - PLATFORM_CONTIGUOUS_BASE) / PAGE_SIZE_BYTES] : PAGE_READWRITE;
memory_watch_forget((void *)start, end - start);
+#ifdef HALO_WEB
+ /* WebAssembly memory has no page protection: the pages stay writable */
+ if (0)
+#else
if (mprotect((void *)start, end - start, protection_to_host(new_protect)) != 0)
+#endif
{
platform_set_last_error_from_errno(errno);
return FALSE;
diff --git a/port/linux/src/xbox_textures.c b/port/linux/src/xbox_textures.c
index eb587bfc1b..1f9a38b01c 100644
--- a/port/linux/src/xbox_textures.c
+++ b/port/linux/src/xbox_textures.c
@@ -21,7 +21,7 @@ memory_watch.c detects that by write-protecting the pages.
#include "port_config.h"
#include
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
#define GL_BGRA GL_RGBA
#endif
#include
@@ -398,7 +398,7 @@ static void decode_level(const struct xgpu_texture_description *description, uns
}
}
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ---------- DXT decoding, for ES drivers without S3TC (Mali) */
static unsigned long color565(unsigned long value)
@@ -538,7 +538,7 @@ static GLenum compressed_format(unsigned char kind)
/* debug.texture_dump_directory writes level 0 of every upload as a TGA, read back from GL */
static void texture_dump(GLenum target, const struct xgpu_texture_description *description)
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES cannot read textures back */
(void)target;
(void)description;
@@ -586,13 +586,19 @@ static void upload(GLuint texture, GLenum target, const struct xgpu_texture_desc
unsigned long *converted;
unsigned long face, level;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
decode_compressed = description->compressed && !xgpu_capabilities.s3tc;
+#endif
+#ifdef HALO_WEB
+ /* WebGL takes S3TC for 2D textures only, and the top level's sides in
+ multiples of four */
+ if (description->compressed && (target == GL_TEXTURE_3D || (description->width & 3) || (description->height & 3)))
+ decode_compressed = TRUE;
#endif
converted = description->compressed && !decode_compressed ? NULL : malloc(largest * sizeof(unsigned long));
glBindTexture(target, texture);
xgpu_gl_state_invalidate();
-#ifdef HALO_ANDROID
+#if defined(HALO_ANDROID) && !defined(HALO_WEB)
/* converted texels are BGRA in memory (32-bit ARGB words); ES takes
RGBA */
glTexParameteri(target, GL_TEXTURE_SWIZZLE_R, converted ? GL_BLUE : GL_RED);
@@ -623,13 +629,27 @@ static void upload(GLuint texture, GLenum target, const struct xgpu_texture_desc
}
else
{
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
if (decode_compressed)
dxt_decode_level(information.kind, source, (unsigned long)width, (unsigned long)height,
(unsigned long)depth, converted);
else
#endif
decode_level(description, level, source, palette, converted);
+#if defined(HALO_WEB) || defined(HALO_IOS)
+ /* Normalize decoded BGRA on the CPU for WebGL and both iOS
+ drivers, avoiding driver-dependent texture swizzling. */
+ {
+ unsigned long texel, count = (unsigned long)width * (unsigned long)height * (unsigned long)depth;
+
+ for (texel = 0; texel < count; texel++)
+ {
+ unsigned long value = converted[texel];
+
+ converted[texel] = (value & 0xff00ff00UL) | ((value & 0xffUL) << 16) | ((value >> 16) & 0xffUL);
+ }
+ }
+#endif
if (target == GL_TEXTURE_3D)
glTexImage3D(image_target, (GLint)level, GL_RGBA8, width, height, depth, 0, GL_BGRA, GL_UNSIGNED_BYTE, converted);
else
@@ -678,6 +698,9 @@ static struct
unsigned long drop_serial;
} recent_textures[RECENT_TEXTURE_COUNT];
static unsigned long texture_drop_serial = 1;
+#ifdef HALO_WEB
+#define WEB_CHECKED_TEXTURE_BYTES 0x20000
+#endif
static unsigned long texture_frame = 0;
static unsigned long bucket_index(DWORD data, DWORD format_word, DWORD size_word)
@@ -760,7 +783,16 @@ GLuint xgpu_texture_get(const DWORD *resource, const D3DCOLOR *palette, GLenum *
if (!palettized && recent_textures[recent].entry && recent_textures[recent].data == data &&
recent_textures[recent].format_word == format_word && recent_textures[recent].size_word == size_word &&
recent_textures[recent].watch_serial == watch_serial &&
- recent_textures[recent].drop_serial == texture_drop_serial)
+ recent_textures[recent].drop_serial == texture_drop_serial
+#ifdef HALO_WEB
+ /* without page protection a write is only seen by hashing
+ (port/web/src/web_memory_watch.c): small textures, which the game
+ rewrites between draws (the text renderer's character cache), are
+ checked at every lookup; large ones change through file reads, which
+ announce themselves */
+ && recent_textures[recent].entry->size > WEB_CHECKED_TEXTURE_BYTES
+#endif
+ )
{
entry = recent_textures[recent].entry;
entry->last_used_frame = texture_frame;
diff --git a/port/linux/src/xbox_xapi.c b/port/linux/src/xbox_xapi.c
index b15dca159a..8a90ff546b 100644
--- a/port/linux/src/xbox_xapi.c
+++ b/port/linux/src/xbox_xapi.c
@@ -39,6 +39,14 @@ DWORD WINAPI XLaunchNewImageA(LPCSTR image_path, PLAUNCH_DATA launch_data)
(void)launch_data;
/* On the Xbox this reboots into another executable and never returns */
platform_log("XLaunchNewImage(\"%s\"): exiting", image_path ? image_path : "(dashboard)");
+#ifdef HALO_WEB
+ {
+ /* the page offers to start again (port/web/src/web_library.js) */
+ extern void web_js_post(int kind, const char *text);
+
+ web_js_post(5, "The game quit to the Xbox dashboard.");
+ }
+#endif
exit(EXIT_SUCCESS);
}
diff --git a/port/linux/src/xgpu.h b/port/linux/src/xgpu.h
index 3016c4e7e9..9395f63384 100644
--- a/port/linux/src/xgpu.h
+++ b/port/linux/src/xgpu.h
@@ -13,7 +13,7 @@ device itself (d3d8_gl.c).
#include "platform.h"
#include "gl.h"
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* OpenGL ES features that are optional (d3d8_gl.c gl_initialize) */
struct xgpu_capabilities
{
@@ -31,7 +31,7 @@ struct xgpu_capabilities
extern struct xgpu_capabilities xgpu_capabilities;
-/* port/android/guest/runtime/guest_host.h */
+/* port/runtime/guest/runtime/guest_host.h */
int host_gl_has_extension(const char *name);
unsigned int host_gl_read_buffer_word(unsigned int buffer, unsigned int offset);
void host_gl_buffer_write(unsigned int target, unsigned int offset, unsigned int size, const void *data);
@@ -98,7 +98,7 @@ struct nv2a_pixel_shader_key
unsigned long alpha_test_function;
unsigned char fog_enable;
unsigned char fog_table_mode;
- /* inside a visibility test: count the samples that pass (Android) */
+ /* inside a visibility test: count the samples that pass (iOS) */
unsigned char count_samples;
/* a high-res HUD meter (hud_hires.h) drawn with the meter's blend (the
destination kept by the source's alpha): that alpha is eased to 1 by the
@@ -110,7 +110,7 @@ struct nv2a_pixel_shader_key
char *nv2a_pixel_shader_to_glsl(const struct nv2a_pixel_shader_key *key);
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* ES samplers have no LOD bias of their own */
#define XGPU_PIXEL_UNIFORMS_ES "uniform vec4 texture_lod_bias;\n"
#else
diff --git a/port/linux/src/xinput_sdl.c b/port/linux/src/xinput_sdl.c
index 8808294c26..77038b8a71 100644
--- a/port/linux/src/xinput_sdl.c
+++ b/port/linux/src/xinput_sdl.c
@@ -99,7 +99,7 @@ worn stick's drift */
#define STICK_AIMING_DEFLECTION 8000
/* (input.mouse_vertical_sensitivity, read with it) */
-static float vertical_sensitivity = 1.0f;
+static float vertical_sensitivity = 0.1f;
static float mouse_sensitivity(void)
{
@@ -111,7 +111,7 @@ static float mouse_sensitivity(void)
read_at = config_changes();
sensitivity = (float)config_real("input.mouse_sensitivity");
if (sensitivity <= 0.0f)
- sensitivity = 1.0f;
+ sensitivity = 0.1f;
vertical_sensitivity = (float)config_real("input.mouse_vertical_sensitivity");
if (vertical_sensitivity <= 0.0f)
vertical_sensitivity = sensitivity;
@@ -291,7 +291,7 @@ static void keyboard_gamepad(const struct platform_input_state *input, XINPUT_GA
k[SDL_SCANCODE_KP_ENTER]);
pad->bAnalogButtons[XINPUT_GAMEPAD_B] |= analog(k[SDL_SCANCODE_ESCAPE] || k[SDL_SCANCODE_BACKSPACE] ||
(mouse && m[SDL_BUTTON_X1]));
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
/* the system back key (gesture or button) backs out of menus */
pad->bAnalogButtons[XINPUT_GAMEPAD_B] |= analog(k[SDL_SCANCODE_AC_BACK]);
#endif
@@ -600,9 +600,9 @@ static int sdl_gamepads(SDL_Gamepad *gamepads[PORT_COUNT])
ids = SDL_GetGamepads(&count);
if (!ids)
return 0;
-#ifdef HALO_ANDROID
+#ifdef HALO_ILP32
{
- /* Android can list input devices with a few gamepad buttons (the
+ /* Android/iOS can list input devices with a few gamepad buttons (the
emulator's keyboard, some phones' key devices) as generic gamepads:
recognised controllers take the first ports */
int pass;
diff --git a/port/linux/src/xiso.c b/port/linux/src/xiso.c
index 066b430060..61ea613870 100644
--- a/port/linux/src/xiso.c
+++ b/port/linux/src/xiso.c
@@ -58,7 +58,7 @@ includes software developed by in .
*/
/* (the desktop ports only: the Android app imports the game data itself) */
-#ifndef HALO_ANDROID
+#if !defined(HALO_ANDROID) && !defined(HALO_IOS)
#include "platform.h"
#include "posix.h"
diff --git a/port/linux/src/xnet.c b/port/linux/src/xnet.c
index 56838b8310..b1228dbd24 100644
--- a/port/linux/src/xnet.c
+++ b/port/linux/src/xnet.c
@@ -49,6 +49,9 @@ alone peers reach.
#include "posix.h"
#include "port_config.h"
#include "p2p.h"
+#ifdef HALO_WEB
+#include "web_shared.h"
+#endif
#include
#include
@@ -1054,6 +1057,27 @@ INT WSAAPI XNetUnregisterKey(const XNKID *key_identifier)
INT WSAAPI XNetXnAddrToInAddr(const XNADDR *address, const XNKID *key_identifier, IN_ADDR *result)
{
unsigned long peer;
+#ifdef HALO_WEB
+ struct web_shared_state *web = web_shared_state();
+ if (__atomic_load_n(&web->gateway_enabled, __ATOMIC_ACQUIRE))
+ {
+ unsigned int identifier[2] = {0, 0};
+ int index;
+ memcpy(identifier, address->abEnet, 6);
+ for (index = 0; index < WEB_GATEWAY_PEERS; index++)
+ {
+ unsigned int ip = __atomic_load_n(&web->gateway_peers[index][2], __ATOMIC_ACQUIRE);
+ if (ip && identifier[0] == (unsigned int)__atomic_load_n(&web->gateway_peers[index][0], __ATOMIC_RELAXED)
+ && identifier[1] == (unsigned int)__atomic_load_n(&web->gateway_peers[index][1], __ATOMIC_RELAXED))
+ {
+ result->s_addr = ip;
+ return 0;
+ }
+ }
+ *result = address->ina;
+ return 0;
+ }
+#endif
(void)key_identifier;
/* an internet play peer's XNADDR carries its identifier */
@@ -1083,6 +1107,18 @@ DWORD WSAAPI XNetGetTitleXnAddr(XNADDR *address)
address->bSizeOfStruct = sizeof(*address);
address->ina.s_addr = ip;
memcpy(address->abEnet, p2p_identifier(), sizeof(address->abEnet));
+#ifdef HALO_WEB
+ {
+ struct web_shared_state *web = web_shared_state();
+ if (__atomic_load_n(&web->gateway_enabled, __ATOMIC_ACQUIRE))
+ {
+ unsigned int identifier[2];
+ identifier[0] = __atomic_load_n(&web->gateway_identifier[0], __ATOMIC_RELAXED);
+ identifier[1] = __atomic_load_n(&web->gateway_identifier[1], __ATOMIC_RELAXED);
+ memcpy(address->abEnet, identifier, sizeof(address->abEnet));
+ }
+ }
+#endif
return ip ? (XNET_GET_XNADDR_ETHERNET | XNET_GET_XNADDR_DHCP) : XNET_GET_XNADDR_ETHERNET;
}
diff --git a/port/macos/CMakeLists.txt b/port/macos/CMakeLists.txt
new file mode 100644
index 0000000000..da702ea21d
--- /dev/null
+++ b/port/macos/CMakeLists.txt
@@ -0,0 +1,91 @@
+# Native AppKit host around the shared statically compiled Apple guest image.
+cmake_minimum_required(VERSION 3.24)
+project(HaloCE LANGUAGES C CXX OBJC ASM)
+if(CMAKE_SYSTEM_NAME STREQUAL "tvOS")
+ message(FATAL_ERROR "Browser-room integration currently targets iOS, not tvOS")
+endif()
+set(HALO_BUNDLE_IDENTIFIER "org.haloce.macos" CACHE STRING "App bundle identifier")
+get_filename_component(ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
+set(SDL_SHARED OFF CACHE BOOL "" FORCE)
+set(SDL_STATIC ON CACHE BOOL "" FORCE)
+set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
+set(SDL_TESTS OFF CACHE BOOL "" FORCE)
+set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE)
+add_subdirectory("${ROOT}/build/third_party/SDL3" sdl3 EXCLUDE_FROM_ALL)
+set(HALO_PLATFORM_SOURCES host_platform.m)
+set(HALO_INFO_PLIST Info.plist)
+set(HALO_DEVICE_FAMILY "1,2")
+add_executable(HaloCE MACOSX_BUNDLE
+ ${HALO_PLATFORM_SOURCES} "${ROOT}/port/runtime/xiso.c" "${ROOT}/port/apple/game_store.m"
+ ../ios/host/host_main.m ../ios/host/host_memory.c ../ios/host/host_loader.c ../ios/host/host_syscall.c ../ios/host/host_gl.c ../ios/host/host_debug.m ../ios/host/host_metalfx.m ../ios/host/host_graphics.m ../ios/host/host_shader_cache.m ../ios/host/guest_call.S
+ ../ios/host/host_thread.c ../ios/host/host_sdl.c ../ios/network/host_room.m ../ios/network/room_bridge.c
+ "${ROOT}/port/ios/network/host_web_net.c" "${ROOT}/port/ios/network/host_net.c" "${ROOT}/port/ios/network/host_browser.m"
+ "${ROOT}/port/linux/src/posix_files.c" ../ios/host/posix_net.c
+ "${ROOT}/build/ios/host/bridges.c" "${ROOT}/build/ios/embedded/guest_image.S")
+target_include_directories(HaloCE PRIVATE ../ios/host ../ios/network "${ROOT}/port/web/src" "${ROOT}/port/runtime" "${ROOT}/port/runtime/include"
+ "${ROOT}/port/runtime/guest/runtime" "${ROOT}/port/linux/src" "${ROOT}/build/ios/host"
+ "${ROOT}/build/ios/gl_include" "${ROOT}/build/ios/embedded")
+target_compile_definitions(HaloCE PRIVATE HALO_IOS=1 HALO_MACOS=1 HALO_IOS_BROWSER_HOST=1 st_mtim=st_mtimespec st_atim=st_atimespec st_ctim=st_ctimespec)
+target_compile_options(HaloCE PRIVATE -Wno-deprecated-declarations)
+target_compile_options(HaloCE PRIVATE "$<$:-fobjc-arc>")
+target_link_libraries(HaloCE PRIVATE SDL3::SDL3-static "-framework WebKit" "-framework QuartzCore" "-framework Foundation" "-framework AppKit")
+target_link_libraries(HaloCE PRIVATE "-framework UniformTypeIdentifiers" "-framework Metal" "-framework CoreVideo")
+file(GLOB HALO_LICENSES "${ROOT}/build/ios/licenses/*.txt")
+target_sources(HaloCE PRIVATE ${HALO_LICENSES})
+set_source_files_properties(${HALO_LICENSES} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses)
+set_target_properties(HaloCE PROPERTIES MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/${HALO_INFO_PLIST}"
+ XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES
+ XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${HALO_BUNDLE_IDENTIFIER}"
+ XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "${HALO_DEVICE_FAMILY}"
+ XCODE_ATTRIBUTE_CODE_SIGN_STYLE Automatic)
+if(HALO_DEVELOPMENT_TEAM)
+ set_target_properties(HaloCE PROPERTIES XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${HALO_DEVELOPMENT_TEAM}")
+endif()
+
+# Ship the exact browser room protocol alongside its native packet adapter.
+set(HALO_ROOM_SCRIPTS "${ROOT}/port/web/site/net.js" "${ROOT}/port/ios/network/room.js")
+target_sources(HaloCE PRIVATE ${HALO_ROOM_SCRIPTS})
+set_source_files_properties(${HALO_ROOM_SCRIPTS} PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Network)
+
+if(NOT CMAKE_OSX_SYSROOT MATCHES "[Ss]imulator")
+ target_link_libraries(HaloCE PRIVATE "-framework MetalFX")
+endif()
+
+execute_process(COMMAND git describe --always --dirty WORKING_DIRECTORY "${ROOT}"
+ OUTPUT_VARIABLE HALO_BUILD_REVISION OUTPUT_STRIP_TRAILING_WHITESPACE)
+target_compile_definitions(HaloCE PRIVATE HALO_BUILD_REVISION="${HALO_BUILD_REVISION}")
+
+# Embed without linking: explicit symbol dispatch keeps Apple's GLES symbols
+# separate from ANGLE's identically named entry points.
+set(ANGLE_SLICE macos-arm64)
+set(ANGLE_ROOT "${ROOT}/build/third_party/angle/dist")
+set_target_properties(HaloCE PROPERTIES
+ XCODE_EMBED_FRAMEWORKS "${ANGLE_ROOT}/EGL.xcframework/${ANGLE_SLICE}/libEGL.framework;${ANGLE_ROOT}/GLESv2.xcframework/${ANGLE_SLICE}/libGLESv2.framework"
+ XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY YES
+ XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY YES
+ XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/../Frameworks"
+ XCODE_ATTRIBUTE_COPY_PHASE_STRIP NO)
+target_link_libraries(HaloCE PRIVATE "-framework IOSurface")
+
+# ANGLE's macOS EGL shim locates libGLESv2.dylib beside itself. Keep one
+# library image and satisfy that lookup with an in-bundle relative symlink.
+add_custom_command(TARGET HaloCE POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E create_symlink ../libGLESv2.framework/libGLESv2
+ "$/Contents/Frameworks/libEGL.framework/libGLESv2.dylib")
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/apple/DISTRIBUTION.md")
+set_source_files_properties("${ROOT}/port/apple/DISTRIBUTION.md" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses)
+
+target_sources(HaloCE PRIVATE Community.icns)
+set_source_files_properties(Community.icns PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
+
+# Finder uses this localized label when the distributed bundle name matches
+# the full, unlocalized CFBundleDisplayName.
+target_sources(HaloCE PRIVATE en.lproj/InfoPlist.strings)
+set_source_files_properties(en.lproj/InfoPlist.strings PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/third_party/monocypher/LICENCE.md")
+set_source_files_properties("${ROOT}/port/third_party/monocypher/LICENCE.md" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses/Monocypher)
+
+target_sources(HaloCE PRIVATE "${ROOT}/port/third_party/expat/COPYING")
+set_source_files_properties("${ROOT}/port/third_party/expat/COPYING" PROPERTIES MACOSX_PACKAGE_LOCATION Resources/Licenses/Expat)
diff --git a/port/macos/Community.icns b/port/macos/Community.icns
new file mode 100644
index 0000000000..2f7a87dae6
Binary files /dev/null and b/port/macos/Community.icns differ
diff --git a/port/macos/Entitlements.plist b/port/macos/Entitlements.plist
new file mode 100644
index 0000000000..fda6637019
--- /dev/null
+++ b/port/macos/Entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+com.apple.security.app-sandbox
+com.apple.security.files.user-selected.read-only
+com.apple.security.network.client
+com.apple.security.network.server
+
diff --git a/port/macos/Info.plist b/port/macos/Info.plist
new file mode 100644
index 0000000000..37094ac549
--- /dev/null
+++ b/port/macos/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDisplayName
+ Halo: Combat Evolved
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ HaloCE
+ CFBundleIconFile
+ Community
+ CFBundleIdentifier
+ org.haloce.macos
+ CFBundleName
+ Halo: Combat Evolved
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.10
+ CFBundleVersion
+ 14
+ LSMinimumSystemVersion
+ 13.0
+ NSHighResolutionCapable
+
+ NSLocalNetworkUsageDescription
+ Connect to other players on your local network.
+
+
diff --git a/port/macos/README.md b/port/macos/README.md
new file mode 100644
index 0000000000..06415bfa85
--- /dev/null
+++ b/port/macos/README.md
@@ -0,0 +1,69 @@
+# Native Apple Silicon Mac app
+
+Build with `python3 tools/macos_build.py` on an Apple Silicon Mac with Xcode,
+Homebrew LLVM and LLD. The named output is
+`build/macos/Halo: Combat Evolved.app`. The application menu uses the full
+name, while the localized Finder/Dock icon label is **Halo**, matching iOS.
+This shares the iOS signed ARM64 ILP32 guest, address
+arena and ABI bridges, with an AppKit host, SDL keyboard/mouse/gamepads,
+ANGLE Metal rendering, and the same bundled PR #12 room transport. No touch
+overlay is created on macOS. Upstream PC-style menus include **Multiplayer →
+Join Game → Server Browser** for public native servers, and updated game
+settings and pause menus. Apple-specific graphics options remain in the
+separate Settings window. OpenGL ES remains an iOS fallback; this Mac
+package currently ships the Metal backend only.
+
+Mouse sensitivity in the in-game profile settings runs from 1 to 100,
+with a new-install/defaults value of 20. The scale has finer adjustment at
+the low end: 20 uses a 0.10 turn multiplier, below the old menu's 0.25 minimum.
+Vertical sensitivity defaults to SAME. Existing saved values are retained;
+select 20 (or Defaults) and confirm OK to apply the lower setting after updating.
+
+First launch uses a Finder file picker to import a user-owned original Xbox
+PAL or NTSC-US ISO/XISO. A retained private image and map cache are published
+only after readback SHA-256 verification and map validation. See
+[distribution notes](../apple/DISTRIBUTION.md) for storage paths, packaging,
+and unresolved legal clearance.
+
+Choose **Settings…** from the application menu (⌘,) to open a separate
+settings window with MetalFX upscaling, shader-stall recording, clearing
+recorded events, and AirDrop export. iOS continues to present its settings
+as a modal when the phone is shaken. MetalFX and recording settings persist.
+Compile/link stalls are recorded automatically without an extra GPU finish.
+The optional first-use GPU recording synchronizes the GPU and can reduce
+performance. Reports include linked vertex/fragment source associations.
+ANGLE's opaque compiled blobs persist in the private Caches directory with
+a 64 MiB disk budget per GPU/OS/driver namespace. Writes are asynchronous;
+cache eviction is harmless. This reduces repeat compilation, but does not
+precompile every pipeline or guarantee hitch-free first visits.
+
+Native menu tracking pauses the frame loop. An event-tracking timer services
+sound streaming (including DirectSound completions) on the guest main thread
+while inside the event pump, allowing queued music to refill without calling game callbacks
+from the audio worker. `HALO_MAC_TEST_MENU_AUDIO=1` opens a native test menu
+for eight seconds and traces PCM output once per second.
+
+Package the app with:
+
+```
+python3 tools/macos_package.py --identity 'Developer ID Application: YOUR NAME (TEAM)'
+```
+
+The script signs embedded frameworks and the sandboxed app with hardened
+runtime, verifies signatures, and creates a signed DMG. Notarization is a
+separate step using your Apple credentials; the script does not claim that
+its output is notarized or legally cleared for public distribution.
+
+Validated locally: native Metal main menu and Silent Cartographer rendering,
+audio, private-image transaction tests, and signed sandboxed runtime launch.
+Mac controller hardware, Mac/browser crossplay and extended MetalFX behavior
+still need interactive testing. iOS/browser crossplay was validated previously;
+sharing that transport does not establish that every Mac network path works.
+
+The standard application menu includes **Quit** (⌘Q); Dock Quit uses the
+same SDL quit event. SDL must create NSApplication before any AppKit UI so
+its menu and termination overrides are installed. Developer regressions:
+`HALO_MAC_TEST_QUIT=menu` opens Settings through the real menu then quits;
+`HALO_MAC_TEST_QUIT=dock` exercises the application termination selector.
+`HALO_MAC_TEST_SHADER_REPORT=/path/report.json` compiles all captured sources
+with the bundled driver. These test overrides are absent during normal use.
diff --git a/port/macos/en.lproj/InfoPlist.strings b/port/macos/en.lproj/InfoPlist.strings
new file mode 100644
index 0000000000..249a47b35b
--- /dev/null
+++ b/port/macos/en.lproj/InfoPlist.strings
@@ -0,0 +1,3 @@
+/* Keep the full application title while shortening the Finder/Dock label. */
+CFBundleName = "Halo: Combat Evolved";
+CFBundleDisplayName = "Halo";
diff --git a/port/macos/host_platform.m b/port/macos/host_platform.m
new file mode 100644
index 0000000000..5b0025730e
--- /dev/null
+++ b/port/macos/host_platform.m
@@ -0,0 +1,118 @@
+#import
+#import
+#import "../apple/game_store.h"
+#include "ios_host.h"
+#include "host_debug.h"
+#include
+void host_ios_touch_initialize(void) {}
+static NSWindow *gameWindow;
+void *host_macos_game_window(void) {return (__bridge void *)gameWindow;}
+void host_ios_touch_attach(SDL_Window *window) {
+ gameWindow=(__bridge NSWindow *)SDL_GetPointerProperty(SDL_GetWindowProperties(window),SDL_PROP_WINDOW_COCOA_WINDOW_POINTER,NULL);
+ [gameWindow makeKeyAndOrderFront:nil];[NSApp activateIgnoringOtherApps:YES];halo_debug_install_menu();
+ NSApp.mainMenu.itemArray.firstObject.title=@"Halo: Combat Evolved";
+ NSApp.mainMenu.itemArray.firstObject.submenu.title=@"Halo: Combat Evolved";
+ const char *quitTest=getenv("HALO_MAC_TEST_QUIT");
+ if(quitTest) {
+ NSString *mode=[NSString stringWithUTF8String:quitTest];
+ NSTimer *timer=[NSTimer timerWithTimeInterval:8 repeats:NO block:^(NSTimer *timer){
+ (void)timer;
+ halo_debug_test_export();
+ NSMenu *appMenu=NSApp.mainMenu.itemArray.firstObject.submenu;
+ host_logf(HOST_LOG_INFO,"Mac application test: %s, menu items %ld",NSStringFromClass(NSApp.class).UTF8String,(long)appMenu.numberOfItems);
+ if([mode isEqualToString:@"menu"]) {
+ BOOL settingsFound=NO;
+ for(NSMenuItem *item in appMenu.itemArray)if([item.keyEquivalent isEqualToString:@","]) {
+ [NSApp sendAction:item.action to:item.target from:item];
+ for(NSWindow *window in NSApp.windows)if([window.title isEqualToString:@"Settings"] && window.isVisible)settingsFound=YES;
+ break;
+ }
+ if(!settingsFound)host_fatal("Mac application test: Settings menu did not open its window");
+ host_logf(HOST_LOG_INFO,"Mac application test: Settings menu opens separate window");
+ for(NSMenuItem *item in appMenu.itemArray)if(item.action==@selector(terminate:)) {
+ host_logf(HOST_LOG_INFO,"Mac application test: Quit menu command (%s)",item.keyEquivalent.UTF8String);
+ [NSApp sendAction:item.action to:item.target from:item];return;
+ }
+ host_logf(HOST_LOG_ERROR,"Mac application test: no Quit menu item");
+ } else {
+ host_logf(HOST_LOG_INFO,"Mac application test: Dock terminate: path");
+ [NSApp terminate:nil];
+ }
+ }];
+ [NSRunLoop.mainRunLoop addTimer:timer forMode:NSDefaultRunLoopMode];
+ }
+}
+void host_ios_touch_reset(void) {}
+void host_ios_touch_focus(void) {}
+int host_ios_gamepads(uint32_t *out,int capacity){int count=0;SDL_JoystickID *ids=SDL_GetGamepads(&count);int n=MIN(count,capacity);for(int i=0;i