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 [![Join our Discord](https://invidget.switchblade.xyz/9gqcHyr5km)](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` invite +and bridges its socket traffic to one browser session. It links the existing +native P2P signalling, cryptography and KCP transport. It does not run Halo, +load maps, host a match, or change the native peer's protocol. + +A browser must run a source-built engine with the same network version as the +host. The published Apollo binary cannot use this worker. The WebSocket frontend +is in `tools/browser-relay`; this executable itself speaks only to stdin/stdout. + +## Build and test + +On a Linux amd64 machine with GCC multilib, make and Python 3: + +```sh +sudo apt-get install gcc-multilib make python3 +make -C port/relay +make -C port/relay test +``` + +The program deliberately builds with `-m32`: the native P2P types have the game's +32-bit ABI. No XDK, graphics libraries, ISO, SDL or game assets are needed. The +runtime needs the 32-bit glibc loader (`libc6-i386` on Debian/Ubuntu). + +`test_relay.py` starts a private MQTT test broker and two native P2P processes. +One is a socket echo host, and the other bridges browser frames through the real +encrypted native UDP/KCP tunnel. This checks signalling, datagram/stream framing +and malformed input handling; it is not a multiplayer game or public-host test. +The test executable alone defines `HALO_RELAY_TEST`, permits the local test broker +and offers `--echo-host`. The production executable has neither behavior and +ignores the test environment variable. `policy-test` checks production address +filtering separately. + +## Parent-process protocol + +All records on stdin and stdout are: + +``` +u32 little-endian length, u8 type, payload[length - 1] +``` + +Length is 1 through 65,536, counting the type byte. Reads may be fragmented. +There are no newline separators. Stdout contains only framed records. + +Input types: + +- `1`: exactly 64 ASCII hexadecimal bytes, sent once. This is the invite code, + without `halo://join/`. Never put this secret in argv, URLs, logs or errors. +- `2`: one Web network packet, as defined in `port/web/src/web_shared.h`. + +Output types: + +- `2`: one Web network packet. +- `3`: UTF-8 JSON event, with `type` equal to `ready`, `peer` or `error`. + +After receiving a valid invite, the worker emits: + +```json +{"type":"ready","protocol":1,"identifier":"0123456789ab","address":1234567890} +``` + +`identifier` is the worker's newly generated six-byte native identity. `address` +is an unsigned 32-bit IPv4 value in the native network-byte-order representation, +derived from the identity in `100.64.0.0/10`. The browser must use this identity in +`XNADDR.abEnet` and this address as its local network address before game startup. +Numbers above illustrate the JSON shape and are not a real identity/address pair. + +When the native host's authenticated tunnel connects: + +```json +{"type":"peer","identifier":"fedcba987654","address":1234567891,"connected":true} +``` + +The browser installs that identity/address mapping for `XNetXnAddrToInAddr`. +No game packets may be submitted before this event. Loss of the host emits the +same event with `connected:false`, then an error, and closes the session. +Errors contain a fixed `code` without addresses, invite contents or exception text. +EOF exits the worker. The frontend must terminate the worker when its socket closes. + +The 24-byte Web packet header contains `size`, `kind`, `source_ip`, +`destination_ip` (four little-endian uint32 values), source/destination port +(two network-byte-order uint16 values), and payload `length` (little-endian uint32). +The complete packet is padded to four bytes. Kinds: datagram=1, open=2, data=3, +close=4, refuse=5. Padding is not payload. + +## Scope and limits + +- Join-only: one native host per worker. Browser hosting and inbound TCP open + requests are not implemented. +- UDP source/destination ports are limited to Halo system-link ports 5150/5151. + UDP payloads are at most 1,395 bytes, matching the native tunnel's 1,400-byte + inner frame minus its five-byte header. Broadcasts go only to the joined host. +- TCP streams must target native host port 5150. At most 16 stream tuples are + open, payloads are at most 16,000 bytes, and each has a 128 KiB pending budget. +- Only the assigned source identity and the connected host destination are + accepted. The public worker rejects private/reserved native peer candidates. +- Idle sessions expire after five minutes, unconnected invites after 95 seconds, + and all sessions after four hours. Stalled output terminates after one second; + reliable bytes are never silently dropped. +- One worker process per browser session isolates the native stack's global state. + Its game sockets bind an assigned loopback address. Public P2P uses a separate + ephemeral UDP socket; hosting infrastructure must allow UDP in both directions. +- Native invitations are never written to logs. The worker suppresses native + logging because the native hosting path may print invitation secrets. + +This is not a public service by itself. The frontend must enforce authentication, +allowed origins, per-client/global session limits and byte-rate/queue limits, +terminate failed workers, and expose WSS behind TLS. An invite grants access to +its host; it is not service authentication. GitHub Pages cannot run this worker. diff --git a/port/relay/relay_platform.h b/port/relay/relay_platform.h new file mode 100644 index 0000000000..fc8e3c920a --- /dev/null +++ b/port/relay/relay_platform.h @@ -0,0 +1,22 @@ +#ifndef HALO_RELAY_PLATFORM_H +#define HALO_RELAY_PLATFORM_H +/* Only the native networking layer is linked: no XDK, renderer or game data. */ +#include +#include +#include +#include +_Static_assert(sizeof(unsigned long) == 4, "Build the relay with the native 32-bit ABI (-m32)"); +#define WSAEWOULDBLOCK 10035 +#define WSAEINPROGRESS 10036 +/* posix_net translates Winsock socket-option values, as for the game. */ +#undef SOL_SOCKET +#undef SO_SNDBUF +#undef SO_RCVBUF +#define SOL_SOCKET 0xffff +#define SO_SNDBUF 0x1001 +#define SO_RCVBUF 0x1002 +unsigned long GetTickCount(void); +void Sleep(unsigned long milliseconds); +void platform_log(const char *format, ...) __attribute__((format(printf, 1, 2))); +int relay_candidate_allowed(unsigned long address, unsigned short port); +#endif diff --git a/port/relay/relay_shim.c b/port/relay/relay_shim.c new file mode 100644 index 0000000000..8a92228c90 --- /dev/null +++ b/port/relay/relay_shim.c @@ -0,0 +1,84 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include "posix.h" +#include "port_config.h" + +unsigned long GetTickCount(void) +{ + struct timespec value; + clock_gettime(CLOCK_MONOTONIC, &value); + return (unsigned long)((unsigned long long)value.tv_sec * 1000 + value.tv_nsec / 1000000); +} + +void platform_log(const char *format, ...) +{ + /* Native host logging can contain its invite: never forward it to logs. */ + (void)format; +} + +int config_boolean(const char *name) +{ + return !strcmp(name, "network.online") || !strcmp(name, "debug.null_renderer"); +} +long config_integer(const char *name) { (void)name; return 0; } +double config_real(const char *name) { (void)name; return 0; } +const char *config_string(const char *name) +{ + if (!strcmp(name, "network.signalling_brokers")) { +#ifdef HALO_RELAY_TEST + const char *test_broker = getenv("HALO_RELAY_TEST_BROKER"); + if (test_broker) return test_broker; +#endif + return "broker.emqx.io:1883,broker.hivemq.com:1883,test.mosquitto.org:1883"; + } + if (!strcmp(name, "network.stun_servers")) { +#ifdef HALO_RELAY_TEST + return ""; +#else + return "stun.l.google.com:19302,stun.cloudflare.com:3478"; +#endif + } + return ""; +} +int relay_candidate_allowed(unsigned long address, unsigned short port) +{ + uint32_t ip = ntohl((uint32_t)address); + if (!port) return 0; +#ifdef HALO_RELAY_TEST + if (getenv("HALO_RELAY_TEST_BROKER")) return 1; +#endif + /* Public unicast only: no local, private, CGNAT, metadata, multicast, + documentation, benchmarking or reserved ranges. */ + return !((ip >> 24) == 0 || (ip >> 24) == 10 || (ip >> 24) == 127 || + (ip & 0xffc00000u) == 0x64400000u || (ip & 0xffff0000u) == 0xa9fe0000u || + (ip & 0xfff00000u) == 0xac100000u || (ip & 0xffffff00u) == 0xc0000000u || + (ip & 0xffffff00u) == 0xc0000200u || (ip & 0xffff0000u) == 0xc0a80000u || + (ip & 0xffffff00u) == 0xc0586300u || (ip & 0xfffe0000u) == 0xc6120000u || + (ip & 0xffffff00u) == 0xc6336400u || (ip & 0xffffff00u) == 0xcb007100u || + ip >= 0xe0000000u); +} +int posix_upnp_forward_udp(unsigned short port, unsigned short preferred_port, posix_ulong *address, unsigned short *external, + char *error, int size) +{ + (void)port; (void)preferred_port; (void)address; (void)external; + if (size) snprintf(error, (size_t)size, "UPnP is disabled in the relay"); + return 0; +} +void posix_upnp_stop_forwarding_udp(unsigned short port) { (void)port; } +void p2p_discord_update(void) {} +void p2p_discord_set_hosting(const char *secret, int count, int maximum) +{ (void)secret; (void)count; (void)maximum; } + +void p2p_discord_user(char *id, int id_size, char *name, int name_size) +{ if (id_size > 0) id[0] = 0; if (name_size > 0) name[0] = 0; } +void Sleep(unsigned long milliseconds) +{ + struct timespec delay = {milliseconds / 1000, (long)(milliseconds % 1000) * 1000000}; + while (nanosleep(&delay, &delay) && errno == EINTR) {} +} diff --git a/port/relay/relay_worker.c b/port/relay/relay_worker.c new file mode 100644 index 0000000000..b61f29755b --- /dev/null +++ b/port/relay/relay_worker.c @@ -0,0 +1,377 @@ +/* One invite, one native P2P peer and one browser per process. No game code. */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "p2p.h" +#include "p2p_internal.h" +#include "posix.h" + +#define MAX_RECORD 65536u +#define MAX_STREAMS 16 +#define STREAM_PENDING 131072u +#define HEADER 24u +#define DATAGRAM 1u +#define OPEN 2u +#define DATA 3u +#define CLOSE 4u +#define REFUSE 5u +extern unsigned long GetTickCount(void); +extern void p2p_sha256(const void *data, int size, unsigned char *digest); +struct stream { + int used, fd, closing; + uint16_t local_port, remote_port; + uint32_t remote_ip; + size_t pending_size; + unsigned char pending[STREAM_PENDING]; +#ifdef HALO_RELAY_TEST + uint32_t observed, checksum; + int test_echo; +#endif +}; +static struct stream streams[MAX_STREAMS]; +static int udp[2] = {-1, -1}; +static uint32_t local_ip, browser_ip, host_ip; +static unsigned char host_identifier[6]; +static int joined, connected; +static unsigned long started, last_input; +static unsigned char input[MAX_RECORD + 4]; +static size_t input_size; +#ifdef HALO_RELAY_TEST +static int echo_host, host_listener = -1, echo_connections; +#endif + +static uint32_t get32(const unsigned char *b) +{ return (uint32_t)b[0] | (uint32_t)b[1] << 8 | (uint32_t)b[2] << 16 | (uint32_t)b[3] << 24; } +static void put32(unsigned char *b, uint32_t n) +{ b[0] = n; b[1] = n >> 8; b[2] = n >> 16; b[3] = n >> 24; } +static uint16_t get_port(const unsigned char *b) { uint16_t n; memcpy(&n, b, 2); return n; } +static void put_port(unsigned char *b, uint16_t n) { memcpy(b, &n, 2); } +static int nonblock(int fd) { return fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); } +static void write_all(const unsigned char *bytes, size_t size) +{ + unsigned long began = GetTickCount(); + while (size) { + ssize_t count = write(STDOUT_FILENO, bytes, size); + if (count > 0) { bytes += count; size -= (size_t)count; continue; } + if (count < 0 && errno == EINTR) continue; + if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) && + GetTickCount() - began < 1000) { + struct pollfd fd = {STDOUT_FILENO, POLLOUT, 0}; + poll(&fd, 1, 20); continue; + } + _exit(2); /* Close a stalled session; never drop reliable bytes. */ + } +} +static void record(unsigned char type, const void *payload, size_t size) +{ + unsigned char prefix[5]; + put32(prefix, (uint32_t)size + 1); prefix[4] = type; + write_all(prefix, sizeof(prefix)); write_all(payload, size); +} +static void event(const char *json) { record(3, json, strlen(json)); } +static void fail(const char *code) +{ + char message[160]; + snprintf(message, sizeof(message), "{\"type\":\"error\",\"code\":\"%s\"}", code); + event(message); exit(1); +} +static int make_socket(int type, uint16_t port) +{ + struct sockaddr_in local = {0}; + int fd = socket(AF_INET, type, 0); + local.sin_family = AF_INET; local.sin_addr.s_addr = local_ip; local.sin_port = port; + if (fd < 0 || bind(fd, (struct sockaddr *)&local, sizeof(local)) || nonblock(fd)) { + if (fd >= 0) close(fd); + return -1; + } + return fd; +} +static void send_frame(uint32_t kind, uint32_t source, uint16_t source_port, + uint16_t destination_port, const void *payload, size_t length) +{ + unsigned char frame[HEADER + 16000 + 4] = {0}; + size_t size = (HEADER + length + 3) & ~(size_t)3; + if (length > 16000) fail("packet_too_large"); + put32(frame, (uint32_t)size); put32(frame + 4, kind); + put32(frame + 8, source); put32(frame + 12, browser_ip); + put_port(frame + 16, source_port); put_port(frame + 18, destination_port); + put32(frame + 20, (uint32_t)length); + if (length) memcpy(frame + HEADER, payload, length); + record(2, frame, size); +} +static void hex_identifier(const unsigned char *identifier, char *text) +{ + static const char digits[] = "0123456789abcdef"; + for (int i = 0; i < 6; i++) { + text[i*2] = digits[identifier[i] >> 4]; text[i*2+1] = digits[identifier[i] & 15]; + } + text[12] = 0; +} +static void peer_event(int value) +{ + char id[13], message[200]; + hex_identifier(host_identifier, id); + snprintf(message, sizeof(message), + "{\"type\":\"peer\",\"identifier\":\"%s\",\"address\":%u,\"connected\":%s}", + id, host_ip, value ? "true" : "false"); + event(message); +} +static void initialize_peer(void) +{ + char id[13], message[180]; + const unsigned char *identifier = p2p_identifier(); + unsigned char digest[32]; + uint32_t virtual_value; + /* Each worker's fixed game ports get a separate loopback address. */ + local_ip = htonl(0x7f010000u | (uint32_t)identifier[4] << 8 | (1 + identifier[5] % 254)); + p2p_sha256(identifier, 6, digest); + virtual_value = (uint32_t)digest[0] << 16 | (uint32_t)digest[1] << 8 | digest[2]; + while ((virtual_value & 255) == 0 || (virtual_value & 255) == 255) virtual_value++; + browser_ip = htonl(0x64400000u | (virtual_value & 0x3fffffu)); + udp[0] = make_socket(SOCK_DGRAM, htons(5150)); + udp[1] = make_socket(SOCK_DGRAM, htons(5151)); + if (udp[0] < 0 || udp[1] < 0) fail("local_socket_unavailable"); + p2p_initialize(local_ip); + p2p_socket_port(udp[0], 0, 0, htons(5150)); + p2p_socket_port(udp[1], 0, 0, htons(5151)); + hex_identifier(identifier, id); + snprintf(message, sizeof(message), + "{\"type\":\"ready\",\"protocol\":1,\"identifier\":\"%s\",\"address\":%u}", id, browser_ip); + event(message); +} +static void start_join(const unsigned char *code, size_t size) +{ + char invite[2 * (P2P_KEY_HASH_SIZE + P2P_TOKEN_SIZE) + 1]; + unsigned char hash[P2P_KEY_HASH_SIZE]; + if (joined || size != sizeof(invite) - 1) fail("invalid_invite"); + for (size_t i = 0; i < size; i++) { + unsigned char c = code[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + fail("invalid_invite"); + invite[i] = (char)c; + } + invite[size] = 0; + for (int i = 0; i < P2P_KEY_HASH_SIZE; i++) { + char pair[3] = {invite[2*i], invite[2*i+1], 0}; + hash[i] = (unsigned char)strtoul(pair, NULL, 16); + } + p2p_identifier_from_hash(hash, host_identifier); + initialize_peer(); + joined = 1; started = GetTickCount(); + if (!p2p_join_invite(invite)) fail("invalid_invite"); + memset(invite, 0, sizeof(invite)); +} +static struct stream *find_stream(uint16_t local_port, uint32_t remote, uint16_t remote_port) +{ + for (int i = 0; i < MAX_STREAMS; i++) + if (streams[i].used && streams[i].local_port == local_port && + streams[i].remote_ip == remote && streams[i].remote_port == remote_port) return &streams[i]; + return NULL; +} +static void close_stream(struct stream *s, int notify) +{ +#ifdef HALO_RELAY_TEST + if (echo_host) { + char message[160]; + snprintf(message, sizeof(message), + "{\"type\":\"test_stream_closed\",\"bytes\":%u,\"checksum\":%u}", s->observed, s->checksum); + event(message); + notify = 0; + } +#endif + if (notify) send_frame(CLOSE, s->remote_ip, s->remote_port, s->local_port, NULL, 0); + close(s->fd); memset(s, 0, sizeof(*s)); +} +static void game_packet(const unsigned char *frame, size_t size) +{ + uint32_t kind, source, destination, length; + uint16_t source_port, destination_port; + struct sockaddr_in target = {0}; + unsigned long translated; + unsigned short translated_port; + if (!connected || size < HEADER || get32(frame) != size) fail("invalid_packet"); + kind = get32(frame + 4); source = get32(frame + 8); destination = get32(frame + 12); + source_port = get_port(frame + 16); destination_port = get_port(frame + 18); + length = get32(frame + 20); + if (source != browser_ip || length > 16000 || ((HEADER + length + 3) & ~3u) != size || + (destination != host_ip && destination != 0xffffffffu)) fail("invalid_packet"); + target.sin_family = AF_INET; + if (kind == DATAGRAM) { + int source_index = ntohs(source_port) - 5150; + int destination_number = ntohs(destination_port); + if (source_index < 0 || source_index > 1 || length > 1395 || + (destination_number != 5150 && destination_number != 5151)) fail("invalid_datagram"); + /* There is one permitted peer: native broadcasts are sent only there. */ + translated = host_ip; translated_port = destination_port; + if (p2p_outgoing(0, -1, &translated, &translated_port) <= 0) fail("peer_unavailable"); + target.sin_addr.s_addr = (uint32_t)translated; target.sin_port = translated_port; + sendto(udp[source_index], frame + HEADER, length, 0, (struct sockaddr *)&target, sizeof(target)); + return; + } + if (destination != host_ip || ntohs(destination_port) != 5150 || ntohs(source_port) < 1024) + fail("invalid_stream"); + struct stream *s = find_stream(source_port, destination, destination_port); + if (kind == OPEN) { + if (s || length) fail("invalid_stream"); + for (int i = 0; i < MAX_STREAMS; i++) if (!streams[i].used) { s = &streams[i]; break; } + if (!s) fail("stream_limit"); + translated = destination; translated_port = destination_port; + if (p2p_outgoing(1, -1, &translated, &translated_port) <= 0) fail("peer_unavailable"); + target.sin_addr.s_addr = (uint32_t)translated; target.sin_port = translated_port; + int fd = make_socket(SOCK_STREAM, 0); + if (fd < 0 || (connect(fd, (struct sockaddr *)&target, sizeof(target)) < 0 && errno != EINPROGRESS)) { + if (fd >= 0) close(fd); + send_frame(REFUSE, destination, destination_port, source_port, NULL, 0); return; + } + s->used = 1; s->fd = fd; s->local_port = source_port; + s->remote_port = destination_port; s->remote_ip = destination; + } else if (kind == DATA) { + if (!s || s->closing || !length || length > STREAM_PENDING - s->pending_size) fail("stream_backpressure"); + memcpy(s->pending + s->pending_size, frame + HEADER, length); s->pending_size += length; + } else if (kind == CLOSE) { + if (length) fail("invalid_stream"); + if (s) s->closing = 1; + } else fail("invalid_packet_kind"); +} +static void consume_input(void) +{ + ssize_t size = read(STDIN_FILENO, input + input_size, sizeof(input) - input_size); + if (!size) exit(0); + if (size < 0) { if (errno == EAGAIN || errno == EINTR) return; exit(1); } + input_size += (size_t)size; + while (input_size >= 4) { + uint32_t record_size = get32(input); + if (!record_size || record_size > MAX_RECORD) fail("invalid_record"); + if (input_size < 4 + record_size) return; + if (input[4] == 1) start_join(input + 5, record_size - 1); + else if (input[4] == 2) game_packet(input + 5, record_size - 1); + else fail("invalid_record_type"); + last_input = GetTickCount(); + size_t consumed = 4 + record_size; + memmove(input, input + consumed, input_size - consumed); input_size -= consumed; + } +} +static void receive_udp(int index) +{ + unsigned char payload[2048]; + struct sockaddr_in from; + socklen_t from_size = sizeof(from); + ssize_t size; + while ((size = recvfrom(udp[index], payload, sizeof(payload), 0, (struct sockaddr *)&from, &from_size)) >= 0) { +#ifdef HALO_RELAY_TEST + if (echo_host) { + sendto(udp[index], payload, (size_t)size, 0, (struct sockaddr *)&from, from_size); continue; + } +#endif + unsigned long address = from.sin_addr.s_addr; + unsigned short port = from.sin_port; + if (size <= 1395 && p2p_incoming(0, &address, &port) && address == host_ip) + send_frame(DATAGRAM, (uint32_t)address, port, htons(5150 + index), payload, (size_t)size); + } +} +static void service_stream(struct stream *s, short ready) +{ + if (ready & POLLOUT) { + if (s->pending_size) { + ssize_t sent = send(s->fd, s->pending, s->pending_size, 0); + if (sent > 0) { + memmove(s->pending, s->pending + sent, s->pending_size - (size_t)sent); + s->pending_size -= (size_t)sent; + } else if (sent < 0 && errno != EAGAIN && errno != EINTR) { close_stream(s, 1); return; } + } + if (s->closing && !s->pending_size) { close_stream(s, 0); return; } + } + if (ready & (POLLIN | POLLHUP | POLLERR)) { + unsigned char bytes[16000]; + ssize_t got = recv(s->fd, bytes, sizeof(bytes), 0); + if (got > 0) { +#ifdef HALO_RELAY_TEST + if (echo_host) { + s->observed += (uint32_t)got; + for (ssize_t i = 0; i < got; i++) s->checksum = s->checksum * 16777619u ^ bytes[i]; + if (s->test_echo) { + if ((size_t)got > STREAM_PENDING - s->pending_size) fail("test_backpressure"); + memcpy(s->pending + s->pending_size, bytes, (size_t)got); s->pending_size += (size_t)got; + } + return; + } +#endif + send_frame(DATA, s->remote_ip, s->remote_port, s->local_port, bytes, (size_t)got); + } else if (!got || (errno != EAGAIN && errno != EINTR)) close_stream(s, 1); + } +} +int main(int argc, char **argv) +{ + signal(SIGPIPE, SIG_IGN); nonblock(STDIN_FILENO); nonblock(STDOUT_FILENO); + started = last_input = GetTickCount(); +#ifdef HALO_RELAY_TEST + echo_host = argc == 2 && !strcmp(argv[1], "--echo-host") && getenv("HALO_RELAY_TEST_BROKER"); + if (echo_host) { + initialize_peer(); + host_listener = make_socket(SOCK_STREAM, htons(5150)); + if (host_listener < 0 || listen(host_listener, MAX_STREAMS)) fail("test_listener"); + p2p_socket_port(host_listener, 1, 1, htons(5150)); joined = 1; + } else +#endif + if (argc != 1) return 2; + (void)argv; + for (;;) { + struct pollfd fds[4 + MAX_STREAMS]; + int stream_indexes[4 + MAX_STREAMS]; + int count = 0; + fds[count++] = (struct pollfd){STDIN_FILENO, POLLIN, 0}; + for (int i = 0; i < 2; i++) fds[count++] = (struct pollfd){udp[i], POLLIN, 0}; +#ifdef HALO_RELAY_TEST + int listener_index = count; + if (echo_host) fds[count++] = (struct pollfd){host_listener, POLLIN, 0}; +#endif + int first_stream = count; + for (int i = 0; i < MAX_STREAMS; i++) if (streams[i].used) { + stream_indexes[count] = i; + fds[count++] = (struct pollfd){streams[i].fd, POLLIN | + ((streams[i].pending_size || streams[i].closing) ? POLLOUT : 0), 0}; + } + if (poll(fds, (nfds_t)count, 10) < 0 && errno != EINTR) return 1; + if (fds[0].revents & (POLLIN | POLLHUP)) consume_input(); + for (int i = 0; i < 2; i++) if (fds[i+1].revents & POLLIN) receive_udp(i); +#ifdef HALO_RELAY_TEST + if (echo_host) { + const char *invite = p2p_take_clipboard_text(); + if (invite) { char message[160]; snprintf(message, sizeof(message), + "{\"type\":\"test_invite\",\"invite\":\"%s\"}", invite); event(message); } + if (fds[listener_index].revents & POLLIN) { + int fd = accept(host_listener, NULL, NULL); + if (fd >= 0) { + int i; for (i = 0; i < MAX_STREAMS && streams[i].used; i++); + if (i == MAX_STREAMS) close(fd); + else { + nonblock(fd); streams[i].used = 1; streams[i].fd = fd; + streams[i].test_echo = ++echo_connections == 1; + } + } + } + } else +#endif + if (joined) { + unsigned long address; + if (p2p_peer_address(host_identifier, &address)) { + if (!connected) { host_ip = (uint32_t)address; connected = 1; peer_event(1); } + } else if (connected) { peer_event(0); fail("peer_disconnected"); } + else if (GetTickCount() - started > 95000) fail("host_unavailable"); + } + for (int i = first_stream; i < count; i++) + if (streams[stream_indexes[i]].used && fds[i].revents) service_stream(&streams[stream_indexes[i]], fds[i].revents); + if (!joined && GetTickCount() - started > 10000) fail("invite_timeout"); + if (GetTickCount() - last_input > 300000) fail("session_idle"); + if (GetTickCount() - started > 4ul*60*60*1000) fail("session_expired"); + } +} diff --git a/port/relay/test_policy.c b/port/relay/test_policy.c new file mode 100644 index 0000000000..24f6ca0152 --- /dev/null +++ b/port/relay/test_policy.c @@ -0,0 +1,13 @@ +#include +#include +#include +int relay_candidate_allowed(unsigned long address, unsigned short port); +int main(void) { + const uint32_t blocked[] = {0, 0x0a010203, 0x7f000001, 0x64400001, 0xa9fea9fe, + 0xac100001, 0xc0a80001, 0xc0000201, 0xc6336401, 0xcb007101, 0xe0000001, 0xffffffff}; + for (unsigned i = 0; i < sizeof(blocked)/sizeof(blocked[0]); i++) + assert(!relay_candidate_allowed(htonl(blocked[i]), htons(1234))); + assert(relay_candidate_allowed(inet_addr("8.8.8.8"), htons(1234))); + assert(!relay_candidate_allowed(inet_addr("8.8.8.8"), 0)); + return 0; +} diff --git a/port/relay/test_relay.py b/port/relay/test_relay.py new file mode 100644 index 0000000000..05f84db477 --- /dev/null +++ b/port/relay/test_relay.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Real native P2P roundtrip against a local MQTT broker and socket echo host. + +This tests encrypted signalling/UDP/KCP and framing; it does not simulate Halo, +connect to a public broker, join a real match, or prove game protocol compatibility. +""" +import asyncio +import json +import os +from pathlib import Path +import shlex +import struct +import sys + + +def packet(kind, source, destination, source_port, destination_port, payload=b''): + size = (24 + len(payload) + 3) & ~3 + return struct.pack('= SVE_VL_MIN && (vl) <= SVE_VL_MAX) +#define sve_vq_from_vl(vl) ((vl) / SVE_VQ_BYTES) +#define sve_vl_from_vq(vq) ((vq) * SVE_VQ_BYTES) +#define SVE_SIG_ZREG_SIZE(vq) ((unsigned)(vq) * SVE_VQ_BYTES) +#define SVE_SIG_PREG_SIZE(vq) ((unsigned)(vq) * (SVE_VQ_BYTES / 8)) +#define SVE_SIG_FFR_SIZE(vq) SVE_SIG_PREG_SIZE(vq) +#define SVE_SIG_REGS_OFFSET \ + ((sizeof(struct sve_context) + (SVE_VQ_BYTES - 1)) \ + / SVE_VQ_BYTES * SVE_VQ_BYTES) +#define SVE_SIG_ZREGS_OFFSET SVE_SIG_REGS_OFFSET +#define SVE_SIG_ZREG_OFFSET(vq, n) \ + (SVE_SIG_ZREGS_OFFSET + SVE_SIG_ZREG_SIZE(vq) * (n)) +#define SVE_SIG_ZREGS_SIZE(vq) \ + (SVE_SIG_ZREG_OFFSET(vq, SVE_NUM_ZREGS) - SVE_SIG_ZREGS_OFFSET) +#define SVE_SIG_PREGS_OFFSET(vq) \ + (SVE_SIG_ZREGS_OFFSET + SVE_SIG_ZREGS_SIZE(vq)) +#define SVE_SIG_PREG_OFFSET(vq, n) \ + (SVE_SIG_PREGS_OFFSET(vq) + SVE_SIG_PREG_SIZE(vq) * (n)) +#define SVE_SIG_PREGS_SIZE(vq) \ + (SVE_SIG_PREG_OFFSET(vq, SVE_NUM_PREGS) - SVE_SIG_PREGS_OFFSET(vq)) +#define SVE_SIG_FFR_OFFSET(vq) \ + (SVE_SIG_PREGS_OFFSET(vq) + SVE_SIG_PREGS_SIZE(vq)) +#define SVE_SIG_REGS_SIZE(vq) \ + (SVE_SIG_FFR_OFFSET(vq) + SVE_SIG_FFR_SIZE(vq) - SVE_SIG_REGS_OFFSET) +#define SVE_SIG_CONTEXT_SIZE(vq) (SVE_SIG_REGS_OFFSET + SVE_SIG_REGS_SIZE(vq)) +#else +typedef struct { + long double __regs[18+256]; +} mcontext_t; +#endif + +struct sigaltstack { + void *ss_sp; + int ss_flags; + size_t ss_size; +}; + +typedef struct __ucontext { + unsigned long uc_flags; + struct __ucontext *uc_link; + stack_t uc_stack; + sigset_t uc_sigmask; + mcontext_t uc_mcontext; +} ucontext_t; + +#define SA_NOCLDSTOP 1 +#define SA_NOCLDWAIT 2 +#define SA_SIGINFO 4 +#define SA_ONSTACK 0x08000000 +#define SA_RESTART 0x10000000 +#define SA_NODEFER 0x40000000 +#define SA_RESETHAND 0x80000000 +#define SA_RESTORER 0x04000000 + +#endif + +#define SIGHUP 1 +#define SIGINT 2 +#define SIGQUIT 3 +#define SIGILL 4 +#define SIGTRAP 5 +#define SIGABRT 6 +#define SIGIOT SIGABRT +#define SIGBUS 7 +#define SIGFPE 8 +#define SIGKILL 9 +#define SIGUSR1 10 +#define SIGSEGV 11 +#define SIGUSR2 12 +#define SIGPIPE 13 +#define SIGALRM 14 +#define SIGTERM 15 +#define SIGSTKFLT 16 +#define SIGCHLD 17 +#define SIGCONT 18 +#define SIGSTOP 19 +#define SIGTSTP 20 +#define SIGTTIN 21 +#define SIGTTOU 22 +#define SIGURG 23 +#define SIGXCPU 24 +#define SIGXFSZ 25 +#define SIGVTALRM 26 +#define SIGPROF 27 +#define SIGWINCH 28 +#define SIGIO 29 +#define SIGPOLL 29 +#define SIGPWR 30 +#define SIGSYS 31 +#define SIGUNUSED SIGSYS + +#define _NSIG 65 diff --git a/port/runtime/guest/libc/arch/arm64_32/bits/stat.h b/port/runtime/guest/libc/arch/arm64_32/bits/stat.h new file mode 100644 index 0000000000..d403c746a8 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/bits/stat.h @@ -0,0 +1,17 @@ +/* the guest's own layout (32-bit time_t); the system call layer converts +from the kernel's */ +struct stat { + dev_t st_dev; + ino_t st_ino; + mode_t st_mode; + nlink_t st_nlink; + uid_t st_uid; + gid_t st_gid; + dev_t st_rdev; + off_t st_size; + blksize_t st_blksize; + blkcnt_t st_blocks; + struct timespec st_atim; + struct timespec st_mtim; + struct timespec st_ctim; +}; diff --git a/port/runtime/guest/libc/arch/arm64_32/bits/stdint.h b/port/runtime/guest/libc/arch/arm64_32/bits/stdint.h new file mode 100644 index 0000000000..d1b2712199 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/bits/stdint.h @@ -0,0 +1,20 @@ +typedef int32_t int_fast16_t; +typedef int32_t int_fast32_t; +typedef uint32_t uint_fast16_t; +typedef uint32_t uint_fast32_t; + +#define INT_FAST16_MIN INT32_MIN +#define INT_FAST32_MIN INT32_MIN + +#define INT_FAST16_MAX INT32_MAX +#define INT_FAST32_MAX INT32_MAX + +#define UINT_FAST16_MAX UINT32_MAX +#define UINT_FAST32_MAX UINT32_MAX + +#define INTPTR_MIN INT32_MIN +#define INTPTR_MAX INT32_MAX +#define UINTPTR_MAX UINT32_MAX +#define PTRDIFF_MIN INT32_MIN +#define PTRDIFF_MAX INT32_MAX +#define SIZE_MAX UINT32_MAX diff --git a/port/runtime/guest/libc/arch/arm64_32/bits/syscall.h.in b/port/runtime/guest/libc/arch/arm64_32/bits/syscall.h.in new file mode 100644 index 0000000000..4374e80b23 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/bits/syscall.h.in @@ -0,0 +1,306 @@ +#define __NR_io_setup 0 +#define __NR_io_destroy 1 +#define __NR_io_submit 2 +#define __NR_io_cancel 3 +#define __NR_io_getevents 4 +#define __NR_setxattr 5 +#define __NR_lsetxattr 6 +#define __NR_fsetxattr 7 +#define __NR_getxattr 8 +#define __NR_lgetxattr 9 +#define __NR_fgetxattr 10 +#define __NR_listxattr 11 +#define __NR_llistxattr 12 +#define __NR_flistxattr 13 +#define __NR_removexattr 14 +#define __NR_lremovexattr 15 +#define __NR_fremovexattr 16 +#define __NR_getcwd 17 +#define __NR_lookup_dcookie 18 +#define __NR_eventfd2 19 +#define __NR_epoll_create1 20 +#define __NR_epoll_ctl 21 +#define __NR_epoll_pwait 22 +#define __NR_dup 23 +#define __NR_dup3 24 +#define __NR_fcntl 25 +#define __NR_inotify_init1 26 +#define __NR_inotify_add_watch 27 +#define __NR_inotify_rm_watch 28 +#define __NR_ioctl 29 +#define __NR_ioprio_set 30 +#define __NR_ioprio_get 31 +#define __NR_flock 32 +#define __NR_mknodat 33 +#define __NR_mkdirat 34 +#define __NR_unlinkat 35 +#define __NR_symlinkat 36 +#define __NR_linkat 37 +#define __NR_renameat 38 +#define __NR_umount2 39 +#define __NR_mount 40 +#define __NR_pivot_root 41 +#define __NR_nfsservctl 42 +#define __NR_statfs 43 +#define __NR_fstatfs 44 +#define __NR_truncate 45 +#define __NR_ftruncate 46 +#define __NR_fallocate 47 +#define __NR_faccessat 48 +#define __NR_chdir 49 +#define __NR_fchdir 50 +#define __NR_chroot 51 +#define __NR_fchmod 52 +#define __NR_fchmodat 53 +#define __NR_fchownat 54 +#define __NR_fchown 55 +#define __NR_openat 56 +#define __NR_close 57 +#define __NR_vhangup 58 +#define __NR_pipe2 59 +#define __NR_quotactl 60 +#define __NR_getdents64 61 +#define __NR_lseek 62 +#define __NR_read 63 +#define __NR_write 64 +#define __NR_readv 65 +#define __NR_writev 66 +#define __NR_pread64 67 +#define __NR_pwrite64 68 +#define __NR_preadv 69 +#define __NR_pwritev 70 +#define __NR_sendfile 71 +#define __NR_pselect6 72 +#define __NR_ppoll 73 +#define __NR_signalfd4 74 +#define __NR_vmsplice 75 +#define __NR_splice 76 +#define __NR_tee 77 +#define __NR_readlinkat 78 +#define __NR_newfstatat 79 +#define __NR_fstat 80 +#define __NR_sync 81 +#define __NR_fsync 82 +#define __NR_fdatasync 83 +#define __NR_sync_file_range 84 +#define __NR_timerfd_create 85 +#define __NR_timerfd_settime 86 +#define __NR_timerfd_gettime 87 +#define __NR_utimensat 88 +#define __NR_acct 89 +#define __NR_capget 90 +#define __NR_capset 91 +#define __NR_personality 92 +#define __NR_exit 93 +#define __NR_exit_group 94 +#define __NR_waitid 95 +#define __NR_set_tid_address 96 +#define __NR_unshare 97 +#define __NR_futex 98 +#define __NR_set_robust_list 99 +#define __NR_get_robust_list 100 +#define __NR_nanosleep 101 +#define __NR_getitimer 102 +#define __NR_setitimer 103 +#define __NR_kexec_load 104 +#define __NR_init_module 105 +#define __NR_delete_module 106 +#define __NR_timer_create 107 +#define __NR_timer_gettime 108 +#define __NR_timer_getoverrun 109 +#define __NR_timer_settime 110 +#define __NR_timer_delete 111 +#define __NR_clock_settime 112 +#define __NR_clock_gettime 113 +#define __NR_clock_getres 114 +#define __NR_clock_nanosleep 115 +#define __NR_syslog 116 +#define __NR_ptrace 117 +#define __NR_sched_setparam 118 +#define __NR_sched_setscheduler 119 +#define __NR_sched_getscheduler 120 +#define __NR_sched_getparam 121 +#define __NR_sched_setaffinity 122 +#define __NR_sched_getaffinity 123 +#define __NR_sched_yield 124 +#define __NR_sched_get_priority_max 125 +#define __NR_sched_get_priority_min 126 +#define __NR_sched_rr_get_interval 127 +#define __NR_restart_syscall 128 +#define __NR_kill 129 +#define __NR_tkill 130 +#define __NR_tgkill 131 +#define __NR_sigaltstack 132 +#define __NR_rt_sigsuspend 133 +#define __NR_rt_sigaction 134 +#define __NR_rt_sigprocmask 135 +#define __NR_rt_sigpending 136 +#define __NR_rt_sigtimedwait 137 +#define __NR_rt_sigqueueinfo 138 +#define __NR_rt_sigreturn 139 +#define __NR_setpriority 140 +#define __NR_getpriority 141 +#define __NR_reboot 142 +#define __NR_setregid 143 +#define __NR_setgid 144 +#define __NR_setreuid 145 +#define __NR_setuid 146 +#define __NR_setresuid 147 +#define __NR_getresuid 148 +#define __NR_setresgid 149 +#define __NR_getresgid 150 +#define __NR_setfsuid 151 +#define __NR_setfsgid 152 +#define __NR_times 153 +#define __NR_setpgid 154 +#define __NR_getpgid 155 +#define __NR_getsid 156 +#define __NR_setsid 157 +#define __NR_getgroups 158 +#define __NR_setgroups 159 +#define __NR_uname 160 +#define __NR_sethostname 161 +#define __NR_setdomainname 162 +#define __NR_getrlimit 163 +#define __NR_setrlimit 164 +#define __NR_getrusage 165 +#define __NR_umask 166 +#define __NR_prctl 167 +#define __NR_getcpu 168 +#define __NR_gettimeofday 169 +#define __NR_settimeofday 170 +#define __NR_adjtimex 171 +#define __NR_getpid 172 +#define __NR_getppid 173 +#define __NR_getuid 174 +#define __NR_geteuid 175 +#define __NR_getgid 176 +#define __NR_getegid 177 +#define __NR_gettid 178 +#define __NR_sysinfo 179 +#define __NR_mq_open 180 +#define __NR_mq_unlink 181 +#define __NR_mq_timedsend 182 +#define __NR_mq_timedreceive 183 +#define __NR_mq_notify 184 +#define __NR_mq_getsetattr 185 +#define __NR_msgget 186 +#define __NR_msgctl 187 +#define __NR_msgrcv 188 +#define __NR_msgsnd 189 +#define __NR_semget 190 +#define __NR_semctl 191 +#define __NR_semtimedop 192 +#define __NR_semop 193 +#define __NR_shmget 194 +#define __NR_shmctl 195 +#define __NR_shmat 196 +#define __NR_shmdt 197 +#define __NR_socket 198 +#define __NR_socketpair 199 +#define __NR_bind 200 +#define __NR_listen 201 +#define __NR_accept 202 +#define __NR_connect 203 +#define __NR_getsockname 204 +#define __NR_getpeername 205 +#define __NR_sendto 206 +#define __NR_recvfrom 207 +#define __NR_setsockopt 208 +#define __NR_getsockopt 209 +#define __NR_shutdown 210 +#define __NR_sendmsg 211 +#define __NR_recvmsg 212 +#define __NR_readahead 213 +#define __NR_brk 214 +#define __NR_munmap 215 +#define __NR_mremap 216 +#define __NR_add_key 217 +#define __NR_request_key 218 +#define __NR_keyctl 219 +#define __NR_clone 220 +#define __NR_execve 221 +#define __NR_mmap 222 +#define __NR_fadvise64 223 +#define __NR_swapon 224 +#define __NR_swapoff 225 +#define __NR_mprotect 226 +#define __NR_msync 227 +#define __NR_mlock 228 +#define __NR_munlock 229 +#define __NR_mlockall 230 +#define __NR_munlockall 231 +#define __NR_mincore 232 +#define __NR_madvise 233 +#define __NR_remap_file_pages 234 +#define __NR_mbind 235 +#define __NR_get_mempolicy 236 +#define __NR_set_mempolicy 237 +#define __NR_migrate_pages 238 +#define __NR_move_pages 239 +#define __NR_rt_tgsigqueueinfo 240 +#define __NR_perf_event_open 241 +#define __NR_accept4 242 +#define __NR_recvmmsg 243 +#define __NR_wait4 260 +#define __NR_prlimit64 261 +#define __NR_fanotify_init 262 +#define __NR_fanotify_mark 263 +#define __NR_name_to_handle_at 264 +#define __NR_open_by_handle_at 265 +#define __NR_clock_adjtime 266 +#define __NR_syncfs 267 +#define __NR_setns 268 +#define __NR_sendmmsg 269 +#define __NR_process_vm_readv 270 +#define __NR_process_vm_writev 271 +#define __NR_kcmp 272 +#define __NR_finit_module 273 +#define __NR_sched_setattr 274 +#define __NR_sched_getattr 275 +#define __NR_renameat2 276 +#define __NR_seccomp 277 +#define __NR_getrandom 278 +#define __NR_memfd_create 279 +#define __NR_bpf 280 +#define __NR_execveat 281 +#define __NR_userfaultfd 282 +#define __NR_membarrier 283 +#define __NR_mlock2 284 +#define __NR_copy_file_range 285 +#define __NR_preadv2 286 +#define __NR_pwritev2 287 +#define __NR_pkey_mprotect 288 +#define __NR_pkey_alloc 289 +#define __NR_pkey_free 290 +#define __NR_statx 291 +#define __NR_io_pgetevents 292 +#define __NR_rseq 293 +#define __NR_kexec_file_load 294 +#define __NR_pidfd_send_signal 424 +#define __NR_io_uring_setup 425 +#define __NR_io_uring_enter 426 +#define __NR_io_uring_register 427 +#define __NR_open_tree 428 +#define __NR_move_mount 429 +#define __NR_fsopen 430 +#define __NR_fsconfig 431 +#define __NR_fsmount 432 +#define __NR_fspick 433 +#define __NR_pidfd_open 434 +#define __NR_clone3 435 +#define __NR_close_range 436 +#define __NR_openat2 437 +#define __NR_pidfd_getfd 438 +#define __NR_faccessat2 439 +#define __NR_process_madvise 440 +#define __NR_epoll_pwait2 441 +#define __NR_mount_setattr 442 +#define __NR_landlock_create_ruleset 444 +#define __NR_landlock_add_rule 445 +#define __NR_landlock_restrict_self 446 +#define __NR_process_mrelease 448 +#define __NR_futex_waitv 449 +#define __NR_set_mempolicy_home_node 450 +#define __NR_cachestat 451 +#define __NR_fchmodat2 452 diff --git a/port/runtime/guest/libc/arch/arm64_32/bits/user.h b/port/runtime/guest/libc/arch/arm64_32/bits/user.h new file mode 100644 index 0000000000..8a1002aa67 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/bits/user.h @@ -0,0 +1,16 @@ +struct user_regs_struct { + unsigned long long regs[31]; + unsigned long long sp; + unsigned long long pc; + unsigned long long pstate; +}; + +struct user_fpsimd_struct { + __uint128_t vregs[32]; + unsigned int fpsr; + unsigned int fpcr; +}; + +#define ELF_NREG 34 +typedef unsigned long elf_greg_t, elf_gregset_t[ELF_NREG]; +typedef struct user_fpsimd_struct elf_fpregset_t; diff --git a/port/runtime/guest/libc/arch/arm64_32/crt_arch.h b/port/runtime/guest/libc/arch/arm64_32/crt_arch.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/port/runtime/guest/libc/arch/arm64_32/fp_arch.h b/port/runtime/guest/libc/arch/arm64_32/fp_arch.h new file mode 100644 index 0000000000..e69de29bb2 diff --git a/port/runtime/guest/libc/arch/arm64_32/kstat.h b/port/runtime/guest/libc/arch/arm64_32/kstat.h new file mode 100644 index 0000000000..fbd5b9e558 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/kstat.h @@ -0,0 +1,23 @@ +/* the AArch64 kernel's struct stat (fstat, newfstatat), spelled with +fixed-width types so the ILP32 guest reads the kernel's layout */ +struct kstat { + unsigned long long st_dev; + unsigned long long st_ino; + unsigned int st_mode; + unsigned int st_nlink; + unsigned int st_uid; + unsigned int st_gid; + unsigned long long st_rdev; + unsigned long long __pad; + long long st_size; + int st_blksize; + int __pad2; + long long st_blocks; + long long st_atime_sec; + long long st_atime_nsec; + long long st_mtime_sec; + long long st_mtime_nsec; + long long st_ctime_sec; + long long st_ctime_nsec; + unsigned int __unused[2]; +}; diff --git a/port/runtime/guest/libc/arch/arm64_32/pthread_arch.h b/port/runtime/guest/libc/arch/arm64_32/pthread_arch.h new file mode 100644 index 0000000000..0189b807fc --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/pthread_arch.h @@ -0,0 +1,11 @@ +/* The guest has no thread pointer register of its own (tpidr_el0 belongs +to the host's C runtime), so the runtime hands out each thread's struct pthread +(guest/runtime/guest_thread.c). */ +uintptr_t __guest_get_tp(void); + +static inline uintptr_t __get_tp() +{ + return __guest_get_tp(); +} + +#define MC_PC pc diff --git a/port/runtime/guest/libc/arch/arm64_32/reloc.h b/port/runtime/guest/libc/arch/arm64_32/reloc.h new file mode 100644 index 0000000000..1f6d303ea5 --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/reloc.h @@ -0,0 +1 @@ +#define LDSO_ARCH "arm64_32" diff --git a/port/runtime/guest/libc/arch/arm64_32/syscall_arch.h b/port/runtime/guest/libc/arch/arm64_32/syscall_arch.h new file mode 100644 index 0000000000..949f275f0a --- /dev/null +++ b/port/runtime/guest/libc/arch/arm64_32/syscall_arch.h @@ -0,0 +1,48 @@ +/* The guest cannot enter the kernel directly with its own structure +layouts, so every system call musl makes goes to the runtime's dispatcher +(guest/runtime/guest_syscall.c), which performs it through the host. */ +#define __SYSCALL_LL_E(x) (x) +#define __SYSCALL_LL_O(x) (x) + +#define __scc(X) sizeof(1?(X):0ULL) < 8 ? (unsigned long) (X) : (long long) (X) +typedef long long syscall_arg_t; + +long __guest_syscall(long long, long long, long long, long long, long long, long long, long long); + +static __inline long __syscall0(long long n) +{ + return __guest_syscall(n, 0, 0, 0, 0, 0, 0); +} + +static __inline long __syscall1(long long n, long long a) +{ + return __guest_syscall(n, a, 0, 0, 0, 0, 0); +} + +static __inline long __syscall2(long long n, long long a, long long b) +{ + return __guest_syscall(n, a, b, 0, 0, 0, 0); +} + +static __inline long __syscall3(long long n, long long a, long long b, long long c) +{ + return __guest_syscall(n, a, b, c, 0, 0, 0); +} + +static __inline long __syscall4(long long n, long long a, long long b, long long c, long long d) +{ + return __guest_syscall(n, a, b, c, d, 0, 0); +} + +static __inline long __syscall5(long long n, long long a, long long b, long long c, long long d, long long e) +{ + return __guest_syscall(n, a, b, c, d, e, 0); +} + +static __inline long __syscall6(long long n, long long a, long long b, long long c, long long d, long long e, long long f) +{ + return __guest_syscall(n, a, b, c, d, e, f); +} + +#define SYSCALL_NO_TLS 1 +#define IPC_64 0 diff --git a/port/runtime/guest/libc/src_include/features.h b/port/runtime/guest/libc/src_include/features.h new file mode 100644 index 0000000000..3928bd32ec --- /dev/null +++ b/port/runtime/guest/libc/src_include/features.h @@ -0,0 +1,19 @@ +/* +musl's internal features.h, with weak_alias spelled as assembler +directives: the guest is compiled for a Darwin target, which rejects alias +attributes, and its assembly is converted to ELF afterwards +(tools/guest_asm_convert.py), where the alias is ordinary. +*/ + +#ifndef GUEST_FEATURES_H +#define GUEST_FEATURES_H + +#include_next "features.h" + +#undef weak_alias +#define weak_alias(old, new) \ + extern __typeof(old) new; \ + static void *const __guest_alias_##new __attribute__((used)) = (void *)&old; \ + __asm__(".globl _" #new "\n.weak_definition _" #new "\n.set _" #new ", _" #old "\n") + +#endif diff --git a/port/runtime/guest/runtime/guest_host.h b/port/runtime/guest/runtime/guest_host.h new file mode 100644 index 0000000000..065276cdca --- /dev/null +++ b/port/runtime/guest/runtime/guest_host.h @@ -0,0 +1,122 @@ +/* +GUEST_HOST.H + +The host services the guest imports (the guest's view; the host defines +them in port/ios/host). Every name here must also be listed in +port/ios/host_imports.list, which generates the import stubs. + +Parameter types follow the rules in halo_guest_abi.h: 32-bit values are +int or unsigned int, 64-bit values long long, and pointers are passed as +they are. +*/ + +#ifndef __GUEST_HOST_H +#define __GUEST_HOST_H + +/* ---------- process */ + +/* performs a Linux system call on the guest's behalf, converting the +structures whose layout differs; returns the raw result (-errno on failure) */ +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); + +/* Stable runtime log priorities; translated by the native host. */ +void host_log(int priority, const char *text); +void host_abort(const char *reason) __attribute__((noreturn)); +void host_exit(int code) __attribute__((noreturn)); +/* the host's errno on this thread, after a call to a host function */ +int host_errno(void); + +/* ---------- threads + +The guest's thread pointer (its struct pthread) is kept by the host for +each thread. */ + +unsigned int host_get_tp(void); +void host_set_tp(unsigned int thread); +/* starts a host thread with a stack in guest memory that calls the image's +__guest_thread_start(thread); returns 0 or an errno value */ +int host_thread_create(unsigned int thread, unsigned int stack_size); + +/* ---------- memory write tracking (port/linux/src/memory_watch.c) */ + +void host_memory_watch_initialize(void); +void host_memory_watch_protect(unsigned int address, unsigned int size); +unsigned int host_memory_watch_generation(unsigned int address, unsigned int size); +unsigned int host_memory_watch_serial(void); +void host_memory_watch_prepare_write(unsigned int address, unsigned int size); +void host_memory_watch_forget(unsigned int address, unsigned int size); + +/* ---------- SDL (guest/runtime/guest_sdl.c) + +Window, context, gamepad and audio stream objects are small integer +handles on this side. */ + +int host_sdl_init(unsigned int flags); +int host_sdl_set_hint(const char *name, const char *value); +void host_sdl_get_error(char *buffer, unsigned int size); +long long host_sdl_ticks(void); +long long host_sdl_thread_id(void); +unsigned int host_sdl_create_window(const char *title, int width, int height, long long flags); +void host_sdl_window_size(unsigned int window, int *width, int *height); +void host_sdl_warp_mouse(unsigned int window, float x, float y); +void host_sdl_window_size_in_pixels(unsigned int window, int *width, int *height); +int host_sdl_set_relative_mouse(unsigned int window, int enabled); +int host_sdl_gl_set_attribute(int attribute, int value); +unsigned int host_sdl_gl_create_context(unsigned int window); +int host_sdl_gl_make_current(unsigned int window, unsigned int context); +int host_sdl_gl_set_swap_interval(int interval); +int host_sdl_gl_swap_window(unsigned int window); +int host_sdl_poll_event(void *event); +int host_apple_poll_event(void *event, unsigned int audio_service); +void host_sdl_scancode_name(int scancode, char *buffer, unsigned int size); +int host_sdl_scancode_from_name(const char *name); +void host_apple_request_quit(void); +int host_sdl_set_clipboard_text(const char *text); +void host_sdl_get_clipboard_text(char *buffer, unsigned int size); +int host_sdl_get_gamepads(unsigned int *ids, int capacity); +unsigned int host_sdl_open_gamepad(unsigned int id); +unsigned int host_sdl_gamepad_from_id(unsigned int id); +int host_sdl_gamepad_axis(unsigned int gamepad, int axis); +int host_sdl_gamepad_button(unsigned int gamepad, int button); +int host_sdl_gamepad_type(unsigned int gamepad); +int host_sdl_rumble_gamepad(unsigned int gamepad, unsigned int low, unsigned int high, unsigned int milliseconds); +/* callback: void (*)(void *userdata, unsigned int stream, int additional, int total), +called on the audio thread */ +unsigned int host_sdl_open_audio_stream(unsigned int device, const void *spec, unsigned int callback, unsigned int userdata); +int host_sdl_put_audio_stream_data(unsigned int stream, const void *data, int length); +int host_sdl_resume_audio_stream_device(unsigned int stream); + +/* ---------- OpenGL ES */ + +/* copies glGetString(name) (or glGetStringi when index >= 0) */ +void host_gl_get_string(unsigned int name, int index, char *buffer, unsigned int size); +/* nonzero if the context supports the named extension */ +int host_gl_has_extension(const char *name); +/* a 32-bit word of a GL buffer object, waiting for the GPU */ +unsigned int host_gl_read_buffer_word(unsigned int buffer, unsigned int offset); +/* unsynchronized write into the buffer bound to target */ +void host_gl_buffer_write(unsigned int target, unsigned int offset, unsigned int size, const void *data); +/* fences the GPU work queued so far as that of ring slot `slot`; waits for +the GPU to finish the work last fenced for a slot */ +void host_gl_fence_frame(unsigned int slot); +void host_gl_wait_frame(unsigned int slot); + +int host_sdl_show_simple_message_box(unsigned int flags, const char *title, const char *message); + +/* Browser room control uses only fixed-width scalars at the ABI boundary. */ +void host_ios_room_open(void); +int host_ios_room_poll(unsigned int *command); +void host_ios_room_report(const char *json); +int host_ios_room_ping(unsigned int address, unsigned int host, unsigned int epoch); + +unsigned int host_ios_render_height(void); +int host_ios_metalfx_present(unsigned int texture, unsigned int width, unsigned int height, unsigned int output_width, unsigned int output_height); + +int host_ios_browser_mode(void); +void host_ios_browser_open(void); +int host_ios_browser_update(const void *listings, int count, void *chosen); +void host_ios_browser_close(int joining); +void host_ios_browser_error(const char *message); + +#endif diff --git a/port/runtime/guest/runtime/guest_memory_watch.c b/port/runtime/guest/runtime/guest_memory_watch.c new file mode 100644 index 0000000000..a687421a14 --- /dev/null +++ b/port/runtime/guest/runtime/guest_memory_watch.c @@ -0,0 +1,41 @@ +/* +GUEST_MEMORY_WATCH.C + +Guest memory write tracking (port/linux/src/memory_watch.c's interface). +Page protection faults are delivered to the host, which owns signal +handling in the iOS process, so the tracking itself lives there +(port/ios/host/host_memory.c). +*/ + +#include "platform.h" +#include "guest_host.h" + +void memory_watch_initialize(void) +{ + host_memory_watch_initialize(); +} + +void memory_watch_protect(unsigned long address, unsigned long size) +{ + host_memory_watch_protect(address, size); +} + +unsigned long memory_watch_generation(unsigned long address, unsigned long size) +{ + return host_memory_watch_generation(address, size); +} + +unsigned long memory_watch_serial(void) +{ + return host_memory_watch_serial(); +} + +void memory_watch_prepare_write(void *address, unsigned long size) +{ + host_memory_watch_prepare_write((unsigned int)address, size); +} + +void memory_watch_forget(void *address, unsigned long size) +{ + host_memory_watch_forget((unsigned int)address, size); +} diff --git a/port/runtime/guest/runtime/guest_misc.c b/port/runtime/guest/runtime/guest_misc.c new file mode 100644 index 0000000000..9ef0b4a1ad --- /dev/null +++ b/port/runtime/guest/runtime/guest_misc.c @@ -0,0 +1,102 @@ +/* +GUEST_MISC.C + +Runtime support functions the guest's code generator (clang's Darwin +arm64_32 target) or the platform layer expect, which neither the game nor +musl provides. +*/ + +#include "guest_host.h" + +#include +#include + +/* Darwin's combined sine and cosine, which clang substitutes for +sin()/cos() pairs of the same argument */ +struct guest_sincos_result { double sine, cosine; }; +struct guest_sincosf_result { float sine, cosine; }; + +__attribute__((no_builtin)) struct guest_sincos_result __sincos_stret(double x) +{ + struct guest_sincos_result result; + + /* no_builtin: clang would turn sincos(), like a sin() and cos() pair, + back into a call to this function */ + sincos(x, &result.sine, &result.cosine); + return result; +} + +__attribute__((no_builtin)) struct guest_sincosf_result __sincosf_stret(float x) +{ + struct guest_sincosf_result result; + + sincosf(x, &result.sine, &result.cosine); + return result; +} + +/* Darwin's exp10, which clang substitutes for pow(10, x) (so these must +not be written with pow, which would become calls to themselves) */ +__attribute__((no_builtin)) float __exp10f(float x) +{ + return exp10f(x); +} + +__attribute__((no_builtin)) double __exp10(double x) +{ + return exp10(x); +} + +__attribute__((no_builtin)) void __bzero(void *buffer, size_t size) +{ + memset(buffer, 0, size); +} + +/* no_builtin: the loop must not become a call to itself */ +__attribute__((no_builtin)) void memset_pattern4(void *buffer, const void *pattern, size_t size) +{ + unsigned char *out = buffer; + size_t index; + + for (index = 0; index < size; index++) + out[index] = ((const unsigned char *)pattern)[index & 3]; +} + +/* no_builtin: the loop must not become a call to itself */ +__attribute__((no_builtin)) void memset_pattern8(void *buffer, const void *pattern, size_t size) +{ + unsigned char *out = buffer; + size_t index; + + for (index = 0; index < size; index++) + out[index] = ((const unsigned char *)pattern)[index & 7]; +} + +/* no_builtin: the loop must not become a call to itself */ +__attribute__((no_builtin)) void memset_pattern16(void *buffer, const void *pattern, size_t size) +{ + unsigned char *out = buffer; + size_t index; + + for (index = 0; index < size; index++) + out[index] = ((const unsigned char *)pattern)[index & 15]; +} + +void __stack_chk_fail(void) +{ + host_abort("stack smashing detected"); +} + +/* : the guest has no unwinder */ +int backtrace(void **frames, int size) +{ + (void)frames; + (void)size; + return 0; +} + +void backtrace_symbols_fd(void *const *frames, int size, int fd) +{ + (void)frames; + (void)size; + (void)fd; +} diff --git a/port/runtime/guest/runtime/guest_sdl.c b/port/runtime/guest/runtime/guest_sdl.c new file mode 100644 index 0000000000..5c495b9685 --- /dev/null +++ b/port/runtime/guest/runtime/guest_sdl.c @@ -0,0 +1,242 @@ +/* +GUEST_SDL.C + +The SDL3 functions the platform layer calls (sdl_platform.c, xinput_sdl.c, +dsound_sdl.c), for the guest. SDL itself runs in the host; objects it +returns (windows, contexts, gamepads, audio streams) are 64-bit pointers +there, so the guest only ever sees small integer handles that the host maps +back (host_sdl.c). Events are written by SDL straight into the guest's +buffer: SDL_Event has the same 128-byte layout in both ABIs for every event +type that carries no pointer, which covers all the platform layer reads. +*/ + +/* before SDL: musl's alloca.h must come first */ +#include +#include +#include +#include + +#include "guest_host.h" + +/* guest/runtime/guest_gl.c (generated) */ +SDL_FunctionPointer guest_gl_get_proc_address(const char *name); + +bool SDL_Init(SDL_InitFlags flags) +{ + return host_sdl_init(flags) != 0; +} + +bool SDL_SetHint(const char *name, const char *value) +{ + return host_sdl_set_hint(name, value) != 0; +} + +const char *SDL_GetError(void) +{ + static __thread char buffer[256]; + + host_sdl_get_error(buffer, sizeof(buffer)); + return buffer; +} + +const char *SDL_GetScancodeName(SDL_Scancode scancode) +{ + static __thread char buffer[64]; + + host_sdl_scancode_name((int)scancode, buffer, sizeof(buffer)); + return buffer; +} + +SDL_Scancode SDL_GetScancodeFromName(const char *name) +{ + return (SDL_Scancode)host_sdl_scancode_from_name(name); +} + +Uint64 SDL_GetTicks(void) +{ + return (Uint64)host_sdl_ticks(); +} + +SDL_ThreadID SDL_GetCurrentThreadID(void) +{ + return (SDL_ThreadID)host_sdl_thread_id(); +} + +void SDL_free(void *memory) +{ + free(memory); +} + +/* ---------- the clipboard (internet play's invite links, sdl_platform.c) */ + +bool SDL_SetClipboardText(const char *text) +{ + return host_sdl_set_clipboard_text(text) != 0; +} + +char *SDL_GetClipboardText(void) +{ + char buffer[1024]; + + host_sdl_get_clipboard_text(buffer, sizeof(buffer)); + return strdup(buffer); +} + +void SDL_Delay(Uint32 milliseconds) +{ + struct timespec duration; + + duration.tv_sec = milliseconds / 1000; + duration.tv_nsec = (long)(milliseconds % 1000) * 1000000L; + nanosleep(&duration, NULL); +} + +/* ---------- video */ + +SDL_Window *SDL_CreateWindow(const char *title, int width, int height, SDL_WindowFlags flags) +{ + return (SDL_Window *)host_sdl_create_window(title, width, height, (long long)flags); +} + +bool SDL_GetWindowSizeInPixels(SDL_Window *window, int *width, int *height) +{ + int w = 0, h = 0; + + host_sdl_window_size_in_pixels((unsigned int)window, &w, &h); + if (width) + *width = w; + if (height) + *height = h; + return true; +} + +bool SDL_SetWindowRelativeMouseMode(SDL_Window *window, bool enabled) +{ + return host_sdl_set_relative_mouse((unsigned int)window, enabled) != 0; +} + +bool SDL_GL_SetAttribute(SDL_GLAttr attribute, int value) +{ + return host_sdl_gl_set_attribute((int)attribute, value) != 0; +} + +SDL_GLContext SDL_GL_CreateContext(SDL_Window *window) +{ + return (SDL_GLContext)host_sdl_gl_create_context((unsigned int)window); +} + +bool SDL_GL_MakeCurrent(SDL_Window *window, SDL_GLContext context) +{ + return host_sdl_gl_make_current((unsigned int)window, (unsigned int)context) != 0; +} + +bool SDL_GL_SetSwapInterval(int interval) +{ + return host_sdl_gl_set_swap_interval(interval) != 0; +} + +bool SDL_GL_SwapWindow(SDL_Window *window) +{ + return host_sdl_gl_swap_window((unsigned int)window) != 0; +} + +SDL_FunctionPointer SDL_GL_GetProcAddress(const char *name) +{ + return guest_gl_get_proc_address(name); +} + +/* ---------- events */ + +bool SDL_PollEvent(SDL_Event *event) +{ + SDL_Event scratch; + +#ifdef HALO_IOS + /* AppKit menus suspend the frame loop. Service sound streaming + on that same thread while its event dispatch is suspended. */ + extern void sound_idle_for_native_menu(void); + return host_apple_poll_event(event ? event : &scratch, (unsigned int)(uintptr_t)sound_idle_for_native_menu) != 0; +#else + return host_sdl_poll_event(event ? event : &scratch) != 0; +#endif +} + +/* ---------- gamepads */ + +SDL_JoystickID *SDL_GetGamepads(int *count) +{ + unsigned int ids[16]; + int found = host_sdl_get_gamepads(ids, 16); + SDL_JoystickID *result = malloc((found + 1) * sizeof(SDL_JoystickID)); + int index; + + if (!result) + return NULL; + for (index = 0; index < found; index++) + result[index] = ids[index]; + result[found] = 0; + if (count) + *count = found; + return result; +} + +SDL_Gamepad *SDL_OpenGamepad(SDL_JoystickID id) +{ + return (SDL_Gamepad *)host_sdl_open_gamepad(id); +} + +SDL_Gamepad *SDL_GetGamepadFromID(SDL_JoystickID id) +{ + return (SDL_Gamepad *)host_sdl_gamepad_from_id(id); +} + +Sint16 SDL_GetGamepadAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) +{ + return (Sint16)host_sdl_gamepad_axis((unsigned int)gamepad, (int)axis); +} + +bool SDL_GetGamepadButton(SDL_Gamepad *gamepad, SDL_GamepadButton button) +{ + return host_sdl_gamepad_button((unsigned int)gamepad, (int)button) != 0; +} + +SDL_GamepadType SDL_GetGamepadType(SDL_Gamepad *gamepad) +{ + return (SDL_GamepadType)host_sdl_gamepad_type((unsigned int)gamepad); +} + +bool SDL_RumbleGamepad(SDL_Gamepad *gamepad, Uint16 low, Uint16 high, Uint32 milliseconds) +{ + return host_sdl_rumble_gamepad((unsigned int)gamepad, low, high, milliseconds) != 0; +} + +/* ---------- audio */ + +SDL_AudioStream *SDL_OpenAudioDeviceStream(SDL_AudioDeviceID device, const SDL_AudioSpec *spec, + SDL_AudioStreamCallback callback, void *userdata) +{ + return (SDL_AudioStream *)host_sdl_open_audio_stream(device, spec, (unsigned int)callback, + (unsigned int)userdata); +} + +bool SDL_PutAudioStreamData(SDL_AudioStream *stream, const void *data, int length) +{ + return host_sdl_put_audio_stream_data((unsigned int)stream, data, length) != 0; +} + +bool SDL_ResumeAudioStreamDevice(SDL_AudioStream *stream) +{ + return host_sdl_resume_audio_stream_device((unsigned int)stream) != 0; +} + +bool SDL_ShowSimpleMessageBox(SDL_MessageBoxFlags flags, const char *title, const char *message, SDL_Window *window) +{ + (void)window; + return host_sdl_show_simple_message_box((unsigned int)flags, title, message) != 0; +} + +bool SDL_GetWindowSize(SDL_Window *window,int *width,int *height) { + int w=0,h=0;host_sdl_window_size((unsigned int)window,&w,&h); + if(width)*width=w;if(height)*height=h;return w>0 && h>0; +} +void SDL_WarpMouseInWindow(SDL_Window *window,float x,float y) {host_sdl_warp_mouse((unsigned int)window,x,y);} diff --git a/port/runtime/guest/runtime/guest_start.c b/port/runtime/guest/runtime/guest_start.c new file mode 100644 index 0000000000..accafb94f0 --- /dev/null +++ b/port/runtime/guest/runtime/guest_start.c @@ -0,0 +1,66 @@ +/* +GUEST_START.C + +Entry point of the guest image. The host maps the image, fills in its +import table and calls __guest_start on the main thread, already running on +a stack in guest memory. This sets up musl (thread pointer, environment, +page size), runs the image's constructors and calls the game's main(). +*/ + +#include "pthread_impl.h" +#include "libc.h" +#include "guest_host.h" +#include "halo_guest_abi.h" + +#include +#include + +extern char __guest_image_end[]; +extern unsigned long long __host_import_table[]; +extern const char __host_import_names[]; +extern const unsigned int __host_import_count; +extern void (*__guest_init_start[])(void); +extern void (*__guest_init_end[])(void); + +void __guest_start(const struct halo_guest_boot *boot); +void __guest_thread_start(unsigned int thread); +unsigned int __guest_thread_attach(void); +void __guest_thread_initialize_main(void); + +extern char **__environ; + +__attribute__((section("__TEXT,__guest_header"), used)) +const struct halo_guest_header __guest_header = +{ + HALO_GUEST_MAGIC, + HALO_GUEST_ABI_VERSION, + (uint32_t)__guest_image_end, + (uint32_t)__host_import_table, + (uint32_t)__host_import_names, + (uint32_t)&__host_import_count, + (uint32_t)__guest_start, + (uint32_t)__guest_thread_start, + (uint32_t)__guest_thread_attach, + (uint32_t)__guest_init_start, + (uint32_t)__guest_init_end, +}; + +/* the game's entry point (source/main/main.c) */ +extern int main(int argc, char **argv); + +void __guest_start(const struct halo_guest_boot *boot) +{ + void (**constructor)(void); + int result; + + libc.page_size = boot->page_size; + libc.auxv = (size_t *)"\0\0\0\0\0\0\0"; + __environ = (char **)boot->environment; + __guest_thread_initialize_main(); + + for (constructor = __guest_init_start; constructor < __guest_init_end; constructor++) + (*constructor)(); + + result = main((int)boot->argc, (char **)boot->argv); + exit(result); +} diff --git a/port/runtime/guest/runtime/guest_syscall.c b/port/runtime/guest/runtime/guest_syscall.c new file mode 100644 index 0000000000..657006494b --- /dev/null +++ b/port/runtime/guest/runtime/guest_syscall.c @@ -0,0 +1,16 @@ +/* +GUEST_SYSCALL.C + +musl's system calls (arch/arm64_32/syscall_arch.h). The guest cannot enter +the kernel itself: the structures it would pass (timespec, stat, iovec) have +ILP32 layouts, and memory it maps must stay below 4 GB. The host performs +each call, converting where needed (host_syscall.c). +*/ + +#include "guest_host.h" + +long __guest_syscall(long long number, long long a, long long b, long long c, + long long d, long long e, long long f) +{ + return (long)host_syscall(number, a, b, c, d, e, f); +} diff --git a/port/runtime/guest/runtime/guest_thread.c b/port/runtime/guest/runtime/guest_thread.c new file mode 100644 index 0000000000..9e43c135cb --- /dev/null +++ b/port/runtime/guest/runtime/guest_thread.c @@ -0,0 +1,259 @@ +/* +GUEST_THREAD.C + +Threads for the guest's musl runtime. + +musl finds the current thread's struct pthread through the thread pointer +register, which in this process belongs to the host's C runtime. The guest's +thread pointer is instead kept by the host per thread (host_get_tp), and +every guest thread is started by the host on a stack inside guest memory +(host_thread_create), since ILP32 code cannot address a stack above 4 GB. + +musl's own synchronisation (mutexes, condition variables, stdio and malloc +locks) is used unchanged: it is built on futexes, which the host passes +through to the kernel, and the guest's addresses are valid host addresses. +Thread creation, joining and detaching are implemented here, together with +the emulated thread-local storage clang generates under -femulated-tls. +*/ + +#include "pthread_impl.h" +#include "stdio_impl.h" +#include "lock.h" +#include "guest_host.h" + +#include +#include + +struct guest_thread +{ + struct pthread pthread; + void *(*start)(void *); + void *argument; + /* _thread_running, then _thread_exited once start returns; or + _thread_detached if detached first */ + volatile int state; + void **emutls; + size_t emutls_count; +}; + +enum +{ + _thread_running = 1, + _thread_detached, + _thread_exited, +}; + +#define DEFAULT_STACK_SIZE (1024 * 1024) + +static struct guest_thread main_thread; + +uintptr_t __guest_get_tp(void) +{ + return host_get_tp(); +} + +static void thread_initialize(struct guest_thread *thread) +{ + struct pthread *td = &thread->pthread; + + td->self = td; + td->prev = td->next = td; + td->detach_state = DT_JOINABLE; + td->locale = &libc.global_locale; + td->robust_list.head = &td->robust_list.head; + thread->state = _thread_running; +} + +/* called by __guest_start on the main thread before anything else */ +void __guest_thread_initialize_main(void) +{ + thread_initialize(&main_thread); + host_set_tp((unsigned int)&main_thread); + main_thread.pthread.tid = __syscall(SYS_gettid); + libc.can_do_threads = 1; + /* lock stdio from the start: the platform layer logs from several + threads, and the standard streams are otherwise never locked */ + libc.threaded = 1; + stdin->lock = 0; + stdout->lock = 0; + stderr->lock = 0; +} + +static void thread_free(struct guest_thread *thread) +{ + size_t index; + + for (index = 0; index < thread->emutls_count; index++) + free(thread->emutls[index]); + free(thread->emutls); + free(thread); +} + +/* the host calls this (image header) on the new thread's guest stack */ +void __guest_thread_start(unsigned int handle) +{ + struct guest_thread *thread = (struct guest_thread *)handle; + void *result; + + host_set_tp(handle); + thread->pthread.tid = __syscall(SYS_gettid); + result = thread->start(thread->argument); + thread->pthread.result = result; + if (a_cas(&thread->state, _thread_running, _thread_exited) == _thread_detached) + { + thread_free(thread); + } + else + { + __wake(&thread->state, -1, 1); + } + host_set_tp(0); +} + +/* a host thread (the audio callback's, for instance) is about to run +guest code: give it a struct pthread */ +unsigned int __guest_thread_attach(void) +{ + struct guest_thread *thread = calloc(1, sizeof(*thread)); + + if (!thread) + host_abort("cannot allocate a guest thread"); + thread_initialize(thread); + thread->state = _thread_detached; + host_set_tp((unsigned int)thread); + thread->pthread.tid = __syscall(SYS_gettid); + return (unsigned int)thread; +} + +int pthread_create(pthread_t *restrict result, const pthread_attr_t *restrict attributes, + void *(*start)(void *), void *restrict argument) +{ + struct guest_thread *thread = calloc(1, sizeof(*thread)); + size_t stack_size = DEFAULT_STACK_SIZE; + int error; + + if (!thread) + return EAGAIN; + thread_initialize(thread); + thread->start = start; + thread->argument = argument; + if (attributes) + { + if (attributes->_a_stacksize > stack_size) + stack_size = attributes->_a_stacksize; + if (attributes->_a_detach) + { + thread->state = _thread_detached; + thread->pthread.detach_state = DT_DETACHED; + } + } + a_inc(&libc.threads_minus_1); + libc.need_locks = 1; + *result = &thread->pthread; + error = host_thread_create((unsigned int)thread, (unsigned int)stack_size); + if (error) + { + a_dec(&libc.threads_minus_1); + free(thread); + return error; + } + return 0; +} + +int pthread_join(pthread_t handle, void **result) +{ + struct guest_thread *thread = (struct guest_thread *)handle; + int state; + + while ((state = thread->state) != _thread_exited) + { + if (state == _thread_detached) + return EINVAL; + __wait(&thread->state, 0, state, 1); + } + if (result) + *result = thread->pthread.result; + a_dec(&libc.threads_minus_1); + thread_free(thread); + return 0; +} + +int pthread_detach(pthread_t handle) +{ + struct guest_thread *thread = (struct guest_thread *)handle; + + if (a_cas(&thread->state, _thread_running, _thread_detached) == _thread_exited) + { + a_dec(&libc.threads_minus_1); + thread_free(thread); + } + return 0; +} + +void pthread_exit(void *result) +{ + (void)result; + host_abort("pthread_exit is not supported by the iOS guest runtime"); +} + +/* ---------- emulated TLS (clang -femulated-tls) */ + +struct __emutls_control +{ + size_t size; + size_t align; + union + { + uintptr_t index; + void *address; + } object; + void *value; +}; + +static volatile int emutls_lock[1]; +static uintptr_t emutls_next_index = 1; + +void *__emutls_get_address(struct __emutls_control *control) +{ + struct guest_thread *thread = (struct guest_thread *)__pthread_self(); + uintptr_t index = __atomic_load_n(&control->object.index, __ATOMIC_ACQUIRE); + void *storage; + + if (!index) + { + __lock(emutls_lock); + index = control->object.index; + if (!index) + { + index = emutls_next_index++; + __atomic_store_n(&control->object.index, index, __ATOMIC_RELEASE); + } + __unlock(emutls_lock); + } + if (index > thread->emutls_count) + { + size_t count = index + 16; + void **slots = realloc(thread->emutls, count * sizeof(void *)); + + if (!slots) + host_abort("cannot grow emulated TLS"); + memset(slots + thread->emutls_count, 0, (count - thread->emutls_count) * sizeof(void *)); + thread->emutls = slots; + thread->emutls_count = count; + } + storage = thread->emutls[index - 1]; + if (!storage) + { + size_t align = control->align > sizeof(void *) ? control->align : sizeof(void *); + + storage = aligned_alloc(align, (control->size + align - 1) & ~(align - 1)); + if (!storage) + host_abort("cannot allocate emulated TLS"); + if (control->value) + memcpy(storage, control->value, control->size); + else + memset(storage, 0, control->size); + thread->emutls[index - 1] = storage; + } + return storage; +} diff --git a/port/runtime/host_imports.list b/port/runtime/host_imports.list new file mode 100644 index 0000000000..53c4ae223e --- /dev/null +++ b/port/runtime/host_imports.list @@ -0,0 +1,61 @@ +# Host functions the guest imports (declared in port/runtime/guest/runtime/guest_host.h, +# defined in port/ios/host). tools/guest_imports.py generates the stubs. +host_syscall +host_log +host_abort +host_exit +host_get_tp +host_set_tp +host_thread_create +host_memory_watch_initialize +host_memory_watch_protect +host_memory_watch_generation +host_memory_watch_serial +host_memory_watch_prepare_write +host_memory_watch_forget +host_sdl_init +host_sdl_set_hint +host_sdl_get_error +host_sdl_ticks +host_sdl_thread_id +host_sdl_create_window +host_sdl_window_size_in_pixels +host_sdl_set_relative_mouse +host_sdl_gl_set_attribute +host_sdl_gl_create_context +host_sdl_gl_make_current +host_sdl_gl_set_swap_interval +host_sdl_gl_swap_window +host_sdl_poll_event +host_sdl_set_clipboard_text +host_sdl_get_clipboard_text +host_sdl_get_gamepads +host_sdl_open_gamepad +host_sdl_gamepad_from_id +host_sdl_gamepad_axis +host_sdl_gamepad_button +host_sdl_gamepad_type +host_sdl_rumble_gamepad +host_sdl_open_audio_stream +host_sdl_put_audio_stream_data +host_sdl_resume_audio_stream_device +host_gl_get_string +host_gl_has_extension +host_gl_read_buffer_word +host_gl_buffer_write +host_gl_fence_frame +host_gl_wait_frame + +host_errno + +host_sdl_show_simple_message_box +host_ios_room_poll +host_ios_room_report +host_ios_room_ping + +host_ios_room_open +host_ios_render_height +host_ios_metalfx_present + +host_sdl_window_size +host_sdl_warp_mouse diff --git a/port/runtime/include/halo_guest_abi.h b/port/runtime/include/halo_guest_abi.h new file mode 100644 index 0000000000..7daf68af15 --- /dev/null +++ b/port/runtime/include/halo_guest_abi.h @@ -0,0 +1,64 @@ +/* +HALO_GUEST_ABI.H + +The contract between the portable game runtime and native iOS host (see +port/ios/README.md): + +- the guest: the game, the platform layer shared with the Linux port and a + small C runtime, compiled as ILP32 AArch64 code (32-bit pointers) and + linked into a static image with 32-bit offsets inside a native arena; +- the host: a native 64-bit iOS app that embeds signed game code, owns + the process (UIKit, SDL3, OpenGL ES, Darwin) and serves the guest's + requests. + +The guest calls the host through import stubs that jump through a table of +64-bit function pointers the host fills in at load time. Only types whose +layout and register treatment agree between the two ABIs cross this +boundary: 32-bit integers, 64-bit integers, floats, and pointers (which +arm64_32 always passes zero-extended). Structures shared here are made of +fixed-width members only. + +This header is included by both halves. +*/ + +#ifndef __HALO_GUEST_ABI_H +#define __HALO_GUEST_ABI_H + +#include + +/* Guest-relative image offset, immediately above the Xbox memory window. */ +#define HALO_GUEST_IMAGE_BASE 0x88000000u + +/* the Xbox contiguous memory window (port/linux/src/platform.h) */ +#define HALO_GUEST_WINDOW_BASE 0x80000000u +#define HALO_GUEST_WINDOW_SIZE 0x08000000u + +#define HALO_GUEST_MAGIC 0x4f4c4148u /* 'HALO' */ +#define HALO_GUEST_ABI_VERSION 2 /* x27 carries the aligned native arena */ + +/* at HALO_GUEST_IMAGE_BASE */ +struct halo_guest_header +{ + uint32_t magic; + uint32_t abi_version; + uint32_t image_end; /* end of .bss */ + uint32_t import_table; /* uint64_t[import_count], filled by the host */ + uint32_t import_names; /* import_count NUL-terminated names */ + uint32_t import_count; /* address of a uint32_t holding the count */ + uint32_t start; /* void __guest_start(struct halo_guest_boot *) */ + uint32_t thread_start; /* void __guest_thread_start(uint32_t thread) */ + uint32_t thread_attach; /* uint32_t __guest_thread_attach(void) */ + uint32_t init_array_start; /* void (*)(void) entries, 4 bytes each */ + uint32_t init_array_end; +}; + +/* the host's description of the process, handed to __guest_start */ +struct halo_guest_boot +{ + uint32_t argc; + uint32_t argv; /* char ** in guest memory */ + uint32_t environment; /* char ** in guest memory, NULL-terminated */ + uint32_t page_size; +}; + +#endif diff --git a/port/runtime/include/halo_guest_variadic_prototypes.h b/port/runtime/include/halo_guest_variadic_prototypes.h new file mode 100644 index 0000000000..61033d4ce8 --- /dev/null +++ b/port/runtime/include/halo_guest_variadic_prototypes.h @@ -0,0 +1,21 @@ +/* +HALO_ILP32_VARIADIC_PROTOTYPES.H + +Force-included by the iOS build (tools/ios_guest_build.py) into the game +files that call these variadic functions without a prototype in scope. +x86 passes every argument on the stack either way, but the iOS guest's +calling convention (Darwin's arm64) passes variadic arguments on the stack +and the others in registers, so such a call must see the real prototype. +tools/guest_abi_check.py finds the calls. +*/ + +#ifndef __HALO_ILP32_VARIADIC_PROTOTYPES_H +#define __HALO_ILP32_VARIADIC_PROTOTYPES_H + +union real_argb_color; + +void error(short priority, const char *format, ...); +void console_printf(unsigned char clear, char const *format, ...); +void terminal_printf(union real_argb_color const *color, char const *format, ...); + +#endif diff --git a/port/runtime/xiso.c b/port/runtime/xiso.c new file mode 100644 index 0000000000..6fab70e6bf --- /dev/null +++ b/port/runtime/xiso.c @@ -0,0 +1,249 @@ +/* +XISO.C + +Copies the maps folder out of an Xbox disc image (an "xiso"), for the +native iOS app's first start without game data. + +The image's file system is XDVDFS, read as extract-xiso does +(https://github.com/XboxDev/extract-xiso, extract-xiso.c, whose format +handling this follows; its license is below and in +port/third_party/extract-xiso/LICENSE.TXT): 2048-byte sectors; a +volume descriptor at 0x10000 that starts and ends with +"MICROSOFT*XBOX*MEDIA" and gives the root directory's sector and size; and +directories whose entries form a binary tree (each entry: left and right +subtree offsets in 4-byte units, the start sector, the size, attributes, the +name's length and the name, 4-byte aligned). Images made from a whole disc +put the game partition further in, at one of the offsets below. + +The native importer validates the complete map set, then writes to a private +staging directory. A failed or cancelled extraction never publishes maps. + +Some parts of this code are copyright in@fishtank.com. This product +includes software developed by in . + + * Copyright (c) 2003 in + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * + * This product includes software developed by in . + * + * 4. Neither the name of "in" nor the email address "in@fishtank.com" + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED `AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR OR ANY CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#include "xiso.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SECTOR = 2048, MAX_DIRECTORY = 4 << 20, MAX_FILES = 256, COPY_SIZE = 1 << 20 }; +static const char magic[] = "MICROSOFT*XBOX*MEDIA"; +static const uint64_t partitions[] = {0, 0x0FD90000, 0x02080000, 0x18300000}; +static const char *required[] = {"ui.map", "a10.map", "a30.map", "a50.map", "b30.map", "b40.map", + "c10.map", "c20.map", "c40.map", "d20.map", "d40.map"}; +struct entry { char name[256]; uint32_t sector, size; }; +struct image { int fd; uint64_t length, partition; char *error; size_t error_size; }; + +static int fail(struct image *image, const char *format, ...) { + va_list args; va_start(args, format); + if (image->error && image->error_size) vsnprintf(image->error, image->error_size, format, args); + va_end(args); return 0; +} +static uint32_t u32(const unsigned char *p) { + return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; +} +static int read_at(struct image *image, uint64_t offset, void *out, size_t size) { + if (offset > image->length || size > image->length-offset) return 0; + unsigned char *p=out; + while (size) { + ssize_t count=pread(image->fd,p,size,(off_t)offset); + if (count<0 && errno==EINTR) continue; + if (count<=0) return 0; + p+=count; offset+=(uint64_t)count; size-=(size_t)count; + } + return 1; +} +static int cache_header(struct image *image, const unsigned char *header, const char *name, char build[33]) { + if (memcmp(header,"daeh",4) || memcmp(header+2044,"toof",4) || u32(header+4)!=5) + return fail(image,"%s is not an original Xbox Halo map. PC, Custom Edition, and MCC images are unsupported.",name); + memcpy(build,header+64,32); build[32]=0; + if (strcmp(build,"01.01.14.2342") && strcmp(build,"01.10.12.2276")) + return fail(image,"Unsupported Xbox map build in %s. Use Halo: Combat Evolved PAL or NTSC-US.",name); + return 1; +} +int xiso_maps_ready(const char *maps, char *error, size_t error_size) { + struct image image={.error=error,.error_size=error_size}; + char path[2048], expected[33]={0}; + for (size_t i=0;i= (int)sizeof(path)) + return fail(&image,"Game data path is too long."); + image.fd=open(path,O_RDONLY|O_NOFOLLOW); + struct stat st; unsigned char header[2048]; char build[33]; + int ok=image.fd>=0 && !fstat(image.fd,&st) && S_ISREG(st.st_mode) && st.st_size>=2048; + if (ok) {image.length=(uint64_t)st.st_size;ok=read_at(&image,0,header,sizeof(header));} + if (image.fd>=0) close(image.fd); + if (!ok) return fail(&image,"Game data is missing or incomplete. Choose your Halo XISO to import it."); + if (!cache_header(&image,header,required[i],build)) return 0; + if (i && strcmp(build,expected)) return fail(&image,"Game maps contain mixed Xbox releases. Import one complete disc image."); + strcpy(expected,build); + } + return 1; +} +struct walk { struct image *image; const unsigned char *table; unsigned char *visited; + uint32_t size; struct entry *files; int count, directories; }; +static int walk_tree(struct walk *walk, uint32_t offset, unsigned depth) { + if (depth>64 || offset+14>walk->size || (offset&3) || walk->visited[offset/4]) + return fail(walk->image,"The disc image has a damaged directory tree."); + walk->visited[offset/4]=1; + const unsigned char *p=walk->table+offset; + unsigned left=p[0]|p[1]<<8, right=p[2]|p[3]<<8, count=p[13]; + if (left==0xffff) return 1; + if (!count || offset+14+count>walk->size) return fail(walk->image,"The disc image has a damaged filename."); + if (left && !walk_tree(walk,left*4,depth+1)) return 0; + if (!!(p[12]&16)==walk->directories) { + char name[256]; + for (unsigned i=0;i126 || c=='/' || c=='\\' || c==':') + return fail(walk->image,"The disc image contains an unsafe filename."); + name[i]=(char)tolower(c); + } + name[count]=0; + if (!strcmp(name,".") || !strcmp(name,"..")) return fail(walk->image,"The disc image contains an unsafe filename."); + if (walk->directories || (count>4 && !strcmp(name+count-4,".map"))) { + if (walk->count>=MAX_FILES) return fail(walk->image,"The disc image has too many map files."); + for (int i=0;icount;i++) if (!strcmp(walk->files[i].name,name)) + return fail(walk->image,"The disc image contains duplicate filenames."); + struct entry *entry=&walk->files[walk->count++]; + strcpy(entry->name,name); entry->sector=u32(p+4); entry->size=u32(p+8); + uint64_t start=walk->image->partition+(uint64_t)entry->sector*SECTOR; + if (start>walk->image->length || entry->size>walk->image->length-start) + return fail(walk->image,"The disc image is incomplete or truncated."); + } + } + return !right || walk_tree(walk,right*4,depth+1); +} +static int directory(struct image *image, uint32_t sector, uint32_t size, int directories, + struct entry *files, int *count) { + if (!size || size>MAX_DIRECTORY) return fail(image,"The disc image has a damaged directory size."); + unsigned char *table=malloc(size), *visited=calloc((size+3)/4,1); + int ok=0; + if (!table || !visited) {fail(image,"Not enough memory to inspect the disc image.");goto done;} + if (!read_at(image,image->partition+(uint64_t)sector*SECTOR,table,size)) { + fail(image,"Could not read the disc image directory. The image may be incomplete.");goto done; + } + struct walk walk={image,table,visited,size,files,0,directories}; + ok=walk_tree(&walk,0,0); *count=walk.count; +done: free(table);free(visited);return ok; +} +int xiso_extract_maps(const char *image_path, const char *destination, + xiso_progress_proc progress, void *context, char *error, size_t error_size) { + struct image image={.fd=-1,.error=error,.error_size=error_size}; + struct entry *files=calloc(MAX_FILES,sizeof(*files)); + unsigned char *buffer=NULL, header[2048]; + char partial[2048], final[2048], path[2400], expected[33]={0}; + struct stat st; int ok=0,count=0,found=0; + uint32_t root_sector=0,root_size=0; uint64_t total=0,done=0; + if (!files) {fail(&image,"Not enough memory to import the disc image.");goto cleanup;} + image.fd=open(image_path,O_RDONLY); + if (image.fd<0 || fstat(image.fd,&st) || !S_ISREG(st.st_mode)) { + fail(&image,"Could not open the disc image. Download it in Files first, then try again.");goto cleanup; + } + image.length=(uint64_t)st.st_size; + for (size_t i=0;i(uint64_t)space.f_bavail*space.f_frsize) { + fail(&image,"Not enough storage. Free at least %.2f GB for the extracted game maps.",(double)total/1e9);goto cleanup; + } + if (snprintf(partial,sizeof(partial),"%s/maps.partial",destination)>=(int)sizeof(partial) || + snprintf(final,sizeof(final),"%s/maps",destination)>=(int)sizeof(final)) {fail(&image,"Import path is too long.");goto cleanup;} + if (!lstat(final,&st) || errno!=ENOENT) {fail(&image,"The import destination already contains maps.");goto cleanup;} + if (mkdir(partial,0700)) {fail(&image,"Could not create the import folder.");goto cleanup;} + buffer=malloc(COPY_SIZE); + if (!buffer) {fail(&image,"Not enough memory to copy the game maps.");goto cleanup;} + for (int i=0;iCOPY_SIZE?COPY_SIZE:(size_t)remaining; + if (!read_at(&image,start,buffer,chunk)) {fail(&image,"Could not read %s. The image may be incomplete.",files[i].name);copied=0;break;} + size_t written=0; + while (written=0) close(image.fd); + free(buffer);free(files);return ok; +} diff --git a/port/runtime/xiso.h b/port/runtime/xiso.h new file mode 100644 index 0000000000..8f69a2a390 --- /dev/null +++ b/port/runtime/xiso.h @@ -0,0 +1,17 @@ +#ifndef HALO_XISO_H +#define HALO_XISO_H +#include +#include + +/* Return zero to cancel. Called on the extraction thread. */ +typedef int (*xiso_progress_proc)(void *context, const char *file, uint64_t done, uint64_t total); + +/* Validate original Xbox cache headers and the complete campaign map set. */ +int xiso_maps_ready(const char *maps, char *error, size_t error_size); + +/* Extract into a new /maps, staging in maps.partial first. + destination must be a private, existing directory. Existing files are never + replaced. On failure the caller removes its private staging directory. */ +int xiso_extract_maps(const char *image_path, const char *destination, + xiso_progress_proc progress, void *context, char *error, size_t error_size); +#endif diff --git a/port/web/README.md b/port/web/README.md new file mode 100644 index 0000000000..73dbcec90a --- /dev/null +++ b/port/web/README.md @@ -0,0 +1,439 @@ +# Web (iPhone, iPad, Android and desktop browsers) + +`ninja web` builds the game as WebAssembly, with a page that installs as a +home-screen web app: `build/web/site`. On an iPhone or iPad, open the page in +Safari, tap Share, then *Add to Home Screen*. On Android, open it in Chrome +(or Edge, or Samsung Internet) and push *Install app* on the page, or choose +*Install app* in the browser's menu. The installed app runs full screen and +works offline. + +The GitHub Actions workflow `.github/workflows/web.yml` builds and tests the +site for each pushed commit. The public game is packaged and deployed +separately; see [GITHUB_PAGES.md](../../GITHUB_PAGES.md). + +The web build uses the platform layer of the Linux build (`port/linux/src`) +and the code paths of the Android build (OpenGL ES 3, the display's shape). +Refer to [port/linux/README.md](../linux/README.md) and +[port/android/README.md](../android/README.md). + +## Requirements + +To play: + +- iOS or iPadOS 17 or later (Safari, or the installed web app), Android with + a recent Chrome, or a recent Chrome, Edge or Firefox on a computer. The browser needs WebGL 2 in a worker + (OffscreenCanvas), SharedArrayBuffer and the Origin Private File System. +- About 2 GB of free storage for the game data. +- An Xbox disc image (`.iso` or `.xiso`) of Halo: Combat Evolved, any version. + +To build: + +- Python and ninja. +- Emscripten 6 or later: `emcc` on the `PATH`, or the SDK in `~/emsdk` + (`configure.py --web-emcc` names another). Install it with + [emsdk](https://emscripten.org/docs/getting_started/downloads.html). +- A network connection for the first build: `configure.py` downloads the + SDL 3.4.16 headers to `build/web/third_party`. + +``` +python configure.py --release +ninja web +``` + +To try the site on a computer, serve `build/web/site` over HTTP, for example +with `python -m http.server -d build/web/site`, and open +`http://localhost:8000`. The page reloads once while its service worker +starts (see "Cross-origin isolation"). + +## Game data + +1. Open the app. It checks what the browser supports. +2. Push *Choose disc image* and select the disc image. On an iPhone, it can + be in the Files app (iCloud Drive, On My iPhone, a USB drive). +3. Wait while the page copies the `maps` folder (about 1.8 GB) into the + app's private storage. The disc image is read in place and not changed. +4. Push *Play*. + +The copy is kept in the site's Origin Private File System. The saved games +(`z:\`, `u:\`) and `config.toml` are there too. *Settings and data* can +export the saved games as a `.zip` and delete the game data. On iOS, data of +a site that is not installed can be removed by the system after some weeks +without use: install the app to keep it. + +## Controls + +### iPhone rotation and sound + +- If the page stays upright when you turn the phone sideways, Portrait + Orientation Lock may be on. Swipe down from the top-right to open Control + Center and turn it off (the padlock with a circular arrow). +- For sound, swipe down to Control Center, turn off Silent Mode if its control + is available, and raise the volume. Otherwise, use the Ring/Silent switch + or the Action button if it is assigned to Silent Mode. +- On iPhones with a Home button, open Control Center by swiping up from the + bottom instead. + +Apple's guides cover [screen rotation](https://support.apple.com/guide/iphone/rotate-your-iphone-screen-iph3badf94ec/ios), +[Control Center](https://support.apple.com/guide/iphone/use-and-customize-control-center-iph59095ec58/ios), +and [Silent Mode](https://support.apple.com/guide/iphone/silence-iphone-iph81c7fd7d1/ios). + +### Game input + +Settings and data offers an optional **Compact FPS touch controls (gameplay)** +layout. Select it before starting; the original controller layout remains the +default and includes menu-navigation buttons. The compact layout has a floating +movement stick that is invisible at rest, drag-to-aim, and hold-to-fire with +immediate release. Dragging the Fire action also aims. Tap the upper-left +weapon/ammo HUD to swap weapons. Smaller action buttons provide jump, melee, +reload/use, crouch, zoom, grenade throw and grenade-type switching. + +The compact HUD omits D-pad, Start/Select and flashlight controls. It assumes a +full-viewport landscape game canvas. Its input is released on interruption, +backgrounding, resize or controller connection. Test its input handling with +`node --test tools/test_web_touch_controls.mjs` (Node.js 22 or newer). + + +- A controller that the browser knows (Xbox, PlayStation, MFi, Switch Pro): + as on Android. The browser sees a controller only after a button is pushed. +- Touch: the left half of the screen is a stick for moving; drag on the right + half to aim; the buttons are the controller's. The touch controls hide + while a controller is connected. *Settings and data* sets the aim + sensitivity, or turns the touch controls off. +- A keyboard and mouse (iPad or computer): as on Linux. Click the game to lock + the pointer; Esc releases it. Hold Tab (or F1) to show the multiplayer + scoreboard; `(HOST)` identifies the current host beside their player name + and follows host migrations. The Ping column shows each player's measured + round-trip latency to that host in milliseconds (host: `0`, unavailable or + stale: `--`). The host probes every three seconds and shares its measurements; + changing hosts clears the previous measurements. Use the mouse wheel to + change weapons. +- Empty or whitespace-only player names receive a random, unique name from the + host, including players joining after the match starts. + +## Online play + +The September 29, 2026 release combines automatic startup, solo System Link, +host recovery, room switching and cached-launcher compatibility with the +Chrome/macOS presentation and repeated-rejoin fixes. Each public site's +`deployment.json` identifies the current source commit, runtime and packaged +file hashes. Apply an offered update to use the new worker frame path. + +Historical Chrome testing on the earlier runtime `0780c5587f467c15` confirmed +a one-player Blood Gulch start and a second player joining the running match +(two players on two machines). Both tabs later suffered renderer crashes +with error 11. Those observations describe the old runtime, before the +presentation and reconnect fixes below. + +Chrome-family browsers on macOS use a separate frame path. It reads +the completed default framebuffer into owned RGBA bytes and transfers those +bytes to a 2D page canvas, avoiding Chrome's crashing ImageBitmap serializer. +Readback preserves GL state, reverses rows, and limits queued frames to two. +The presentation height defaults to 480 lines to bound readback cost. Other +browsers retain ImageBitmap presentation, with an empty-frame guard before +transfer. `?frame_transport=rgba` and `?frame_transport=bitmap` select a path +for controlled diagnostics; `?render_height=...` overrides presentation size. +A new launcher can display frames from an older cached runtime; applying +its offered update enables the new worker frame path. + +Reloads retire the old RTC peer at its virtual address, deliver native EOF on +connection loss, and replace its native stream only after the game releases +the old endpoint. Pending validation cleanup closes accepted endpoints too; +joining clients ignore gameplay traffic left over from the prior connection. +Departed players leave their machine's input slots while their scores remain +available. A browser host in distributed Free-for-All Slayer with unlimited lives keeps +waiting when an opponent leaves; score limits and other game modes retain +their own end conditions. These changes have socket and player lifecycle +regressions alongside the browser transport tests. + +The prior local crash/rejoin build passed an initial late join followed by five +consecutive same-address reload/rejoin cycles in Chrome on macOS. The same host match +remained playable for over six minutes, including over two minutes after the +final rejoin. Browser transport and native lifecycle regressions passed for +that build. It predates combining the 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 regressions +under AddressSanitizer and UndefinedBehaviorSanitizer. Physical-iPhone +gameplay, other browsers and connections across different Internet/NAT +networks still need live verification. + +Browser players can play together over the internet, with the game's own +system link. The launcher joins the public **FQLX01** room on a first visit. +Both hosted domains use that same room. Share a link ending in +`?room=FQLX01` to enter it directly. + +Browser rooms need the UI and Beaver Creek maps before entering +multiplayer automatically. The full main menu and desktop invites need all +maps because they can use other scenarios. Import your own Xbox disc image +in the launcher; existing completed maps are reused on later visits. +The first ready player hosts Beaver Creek Slayer; the others join that host +without navigating the game's System Link menus. The public room does not +run a permanent game server. +Players spawn with the human pistol in place of the map's plasma pistol, +including late joins and respawns. + +When the host disconnects, quick play freezes the loaded match and elects a +replacement from surviving players with a complete checkpoint of that match. +The replacement adopts the existing world and roster; the other players +reattach their original machine and player slots. Scores, positions, inventory +and the match timer are retained. There is no automatic new-match fallback. +If recovery cannot complete, the match stays paused until the player explicitly +leaves. Everyone must use the new launcher and runtime. + +A brief lost connection has a 10-second grace period; silent WebRTC channels +are detected after 25 seconds. Election and reconnecting add time after +detection, without loading a new map. Authoritative checkpoints are sent every +15 ticks, so authority-only state can resume from a recent checkpoint rather +than the exact instant a tab crashed. Packet epochs fence previous hosts. +*Main menu*, *Leave*, idle launchers and native invites do not participate. +The room's public brokers are not a consensus service: network partitions can +still produce divergent continuations. See [host migration](../linux/HOST_MIGRATION.md) +for the checkpoint and resume contract and its verification limits. +In historical September 29 testing on runtime `0780c5587f467c15`, Chrome's +saved engine log confirmed that a two-player client joined at tick 655, +lost the host connection, created a replacement local +server and started a new solo match automatically. Those tabs later suffered +the old runtime's renderer crash. The production restart/cancellation checks +under sanitizers and Web/build CI passed for that release; the engine log +does not establish sustained gameplay for the combined update. + +Connection, hosting and map-loading progress are shown on the page. Sound +starts automatically where allowed; normal game input unlocks it and mouse +controls when browser permissions require a gesture. *Main menu* cancels quick +play; `?menu=1` opens the normal launcher and game menu instead. + +For a separate group, choose *New room*, then *Share link*. Opening that +link joins its browser room, and the launcher remembers the chosen room. +The in-game *Room* button opens the room controls. Choosing another room +restarts Halo into that room using the cached maps; *Leave* closes the session +and returns to the launcher. A link joins only the room named in `?room=`. +*Leave* stays out across reloads; *Join default room* returns to FQLX01. +Historical September 29 testing on the earlier runtime switched Chrome from +FQLXQTEST8 into a fresh FQLXQTEST9 +match with cached maps, and a later client joined it as the second player +on a second machine. The tabs subsequently crashed on that old runtime. +Room-navigation regressions passed; these older observations do not verify +sustained gameplay for the combined update. +Manual System Link also allows a one-player start with distributed networking, +including hosting a non-team game while waiting for other players. The host +continues simulating in background tabs. Team readiness checks and lockstep +requirements still apply. + +Quick play starts only when the maps and room are ready. Idle tabs and tabs +importing a disc image do not participate in host selection. + +Everyone in a room is on one network, as on a LAN: up to the game's limits +of machines and players, split screen on each machine included. + +How it works (`site/net.js`, `src/web_net.c`): + +- Each copy of the game has an address on the room's network, 10.x.y.z, kept + in the browser. The game's sockets put what they send to other addresses + in a ring in the shared memory, and take what arrives from another ring. +- The page carries those packets over WebRTC to each other player: a + reliable, ordered data channel for the game's connections and an + unreliable one for its datagrams. Broadcasts (system link's discovery) go + to every player. The connections are direct between the players; no server + carries the game. +- Players find each other through public MQTT brokers over secure WebSockets + (broker.emqx.io, broker.hivemq.com and test.mosquitto.org, all at once), in + a topic derived from the room's code. Everything sent there is encrypted + (AES-GCM) with a key derived from the code, so only those who have the code + can read the room's messages. +- WebRTC crosses most home networks with STUN (Google's and Cloudflare's + public servers). Some networks, mobile carriers' especially, need a TURN + relay: *Settings and data* can name one. + +Desktop and Android invitations use a separate UDP protocol. Joining those +hosts requires the [native relay](../../BROWSER_NATIVE_INVITES.md), which is +not configured on the public sites. Browser rooms do not use that relay. + +## How the port operates + +### WebAssembly + +wasm32 is an ILP32 target: `int`, `long` and pointers have 32 bits, as the +game's data formats need, and the game runs as it does on the other ports. + +- The WebAssembly memory is 0x88000000 bytes and does not grow. Its top + 128 MB is the Xbox memory window at 0x80000000 (`port/linux/src/platform.h`), + so the tag cache and the game state get the fixed addresses of their files. + The C heap stays below it (`src/web_main.c`, `emscripten_get_heap_size`). +- The game and the platform layer are compiled with link-time optimisation. + C89 code calls functions declared differently from their definitions, + which x86 tolerates and WebAssembly traps on; with the whole program in one + module, LLVM gives each such call a wrapper that adapts the arguments. +- The multivalue ABI (`-target-abi experimental-mv`) passes and returns small + structures and unions as values, as Win32 returns them in registers: + `hs_runtime.c` calls functions that return unions through pointers typed as + returning `long`. +- `-mnontrapping-fptoint`: a float that does not fit an integer converts as + on x86 instead of trapping. +- Calls whose declarations disagree with the definition in a way LLVM cannot + adapt (integer widths, since its wrappers only bitcast), and function + pointers called with another signature, are repaired in `#ifdef HALO_WEB`: + local prototypes that now match their definitions (`hs.c`, `rasterizer.c`, + `ui_widget_event_handler_functions.c` and others), the cache thread's start + routine (`cache_files_windows.c`), the stub game engine's callbacks + (`game_engine_stub.c`), `weapon_preprocess_node_orientations`, a `va_list` + (`terminal.c`), and two globals defined in a header (`object_lists.h`; + WebAssembly has no common symbols). An unoptimised link + (`-Wl,--lto-O0`) names any call LLVM could not adapt + `_bitcast_invalid`; only libtiff's remain, which the game does not + use. + +### Threads and the page + +The game runs on a pthread (Emscripten's `PROXY_TO_PTHREAD`), a Web Worker, +so it can block as a native program does. It never returns to its event loop. +The page's main thread (`site/app.js`, `site/input.js`) serves it through +memory both share (`src/web_shared.h`): + +- Graphics: the game's WebGL 2 context draws into an OffscreenCanvas of its + own thread (`src/web_library.js`). Chrome-family browsers on macOS transfer + owned RGBA pixels; other browsers use `transferToImageBitmap`. The page + shows the completed frame on its canvas. The page advances a counter each + animation frame; the game waits for + it after each frame (`display.vsync`). +- Input: the page writes keyboard, mouse and focus events into a ring that + `SDL_PollEvent` reads, and the state of the controllers (Gamepad API, and + the touch controls as one more controller). +- Sound: a thread of the game fills a ring of 48 kHz samples with the mixer of + `dsound_sdl.c`; an AudioWorklet (`site/audio-worklet.js`) plays it. +- Network: split screen is a network game whose host and clients are the + same machine. `src/web_net.c` gives the Winsock layer (`port/linux/src/xnet.c`) + sockets that reach each other inside the page: datagrams to the loopback, + local or a broadcast address go to the socket bound to their port, and + stream sockets connect through queues. `HALO_NET_DEBUG=1` logs the traffic. +- Time: `GetTickCount` and `QueryPerformanceCounter` count from the start, as + an Xbox counts from its boot. A browser's monotonic clock counts from 1970, + past 2^31 milliseconds, and the network code compares tick counts as + signed longs. +- Files: WasmFS mounts the Origin Private File System at `/data`, the data + root (`HALO_DATA_ROOT`); the saved games go to `/data/save`. + +`src/web_sdl.c` gives the platform layer the SDL3 functions it calls (the +Android guest's list, `port/android/guest/runtime/guest_sdl.c`). + +### Browser performance and diagnostics + +By default, the source worker uses the same append-only stream upload batching as the +Apollo runner. The shared recorder is embedded in `halo.js`, installed on the +worker's WebGL context, and flushed before each image is sent to the page. +This avoids repeatedly updating the large vertex/index rings between draws. +It adds about 54 MiB of CPU shadow storage for the existing triple-buffer ring; +shader code, scene resolution, and draw order are unchanged. The source +renderer already caches render state, so the Apollo state-cache wrappers are +not installed a second time. + +Add `?fps=1` to show actual frames received from the game worker. The HUD keeps +up to 120 visible measurement windows in its `data-samples` attribute, including +elapsed time and canvas size; animation callbacks do not count as game frames. +Hidden time is excluded, while visible stalls count toward the measured FPS. +Compare `?fps=1&batch_streams=0` to disable batching (or pass +`--HALO_WEB_BATCH_STREAMS=0` to the runtime). Reload between comparisons. +`?fps=1&render_height=720` overrides the presentation-height cap for diagnostics +(480–1440 lines). The default cap is 480 lines on iPhone/iPad and for RGBA +presentation, and 1440 elsewhere; +the engine still renders 480 lines, and CSS scales the displayed canvas to the +screen. At most two frame transfers can await the page's acknowledgement. +When that queue is full, the worker submits GPU commands without creating +another bitmap or pixel readback, or blocking simulation. Hidden or failed +presentations release their frame and acknowledgement too. + +`?fps=1&geometry_cache=1` enables an experimental native geometry path (runtime +argument `--HALO_WEB_GEOMETRY_CACHE=1`) and automatically disables JavaScript +stream batching. Dynamic vertex/index uploads use separate buffers in three +rotating frame buckets. Vertex ranges observed unchanged twice are retained; +exact comparisons of the uploaded bytes, including color conversion, invalidate +changed data. Streams used by the current draw cannot evict each other. The +cache is limited to 8 MiB of CPU snapshots and up to 8 MiB of retained GPU data. +Stream storage has a 32 MiB target and trims old allocations under pressure; +a single larger draw may exceed that target until subsequent draws can trim it. +Driver-held copies for in-flight draws are outside this accounting. With +`--HALO_GPU_STATS=1`, the log reports cache reuse, upload bytes, and stream +storage/evictions. This path remains opt-in pending browser and device FPS +measurements. + +On September 29, 2026, a local Apple Silicon/Chrome comparison used the +Silent Cartographer opening and the same 1960 × 1044 presentation size. +The updated source binary with `batch_streams=0` returned to roughly 2–4 FPS +in its early opening samples. With batching enabled, two launches measured +about 46–52 and 48–50 FPS in their first two sampling windows; later beach +combat in those runs was around 59–79 FPS. The brief Pelican ride reached the +120 Hz display limit. These are scene-specific local observations, not a +sustained or physical-iPhone performance guarantee. Each launch reused the +same assets and saves; rendering remained at the original quality settings. +Those measurements predate the native geometry cache and presentation changes +above and do not establish their FPS effect. + +Validation covers the DOM-free worker installer, opt-out, explicit flush before +bitmap transfer, existing upload/readback/VAO/uniform snapshot cases, bounded +measurement history, hidden intervals, and visible stalls. Run: + +```sh +node tools/test_stream_batch.mjs +node --test tools/tests/*.test.cjs +python3 tools/test_web_geometry.py +``` + +The native geometry test uses mocked GL storage with AddressSanitizer and +UndefinedBehaviorSanitizer to check changed-byte invalidation, multistream +isolation, exact upload sizes, frame rotation, and cache/storage limits. + +### WebGL 2 + +The renderer takes its OpenGL ES 3.0 path (port/android/README.md), with +these differences for WebGL 2 (`#ifdef HALO_WEB` in `xbox_textures.c` and +`src/web_host.c`): + +- WebGL has no texture swizzle: decoded textures are turned from BGRA to RGBA + on the CPU. +- S3TC textures go to the GPU only as 2D textures whose sides are multiples of + four; the others are decoded. iOS has no S3TC: all are decoded. +- The visibility tests (lens flares) report every sample visible: WebGL gives + query results only between tasks, which the game's thread never reaches. +- Default streamed buffer writes use `glBufferSubData`, which copies. The + opt-in geometry cache uses per-upload `glBufferData` and retained buffers; + neither path uses GPU fences in the browser. +- Strides are at most 255 bytes: the immediate mode's vertices (16 + attributes of 4 floats) go up as one array per attribute. + +The Xbox memory cannot be write-protected in WebAssembly. The memory watch +(`src/web_memory_watch.c`) keeps a hash of each page the renderer caches, and +a changed hash counts as a write. Textures of 128 KB or less, which the game +rewrites between draws (the text renderer's character cache), are checked at +every use; larger ones change through file reads, which announce themselves. + +### Cross-origin isolation + +SharedArrayBuffer needs the page to be cross-origin isolated +(`Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers). +Static hosts such as GitHub Pages cannot send headers, so the service worker +(`site/sw.js`) adds them. The first visit reloads the page once. The service +worker also keeps the site for use offline; a new build replaces it as a +whole when the player accepts the update. + +### The game data + +`site/xiso-worker.js` reads the XDVDFS file system of the disc image as +`port/linux/src/xiso.c` does and writes the files of `maps` with the OPFS +synchronous access handles. It writes `maps/.complete` last; the page starts +the game only when it is there. + +## Limits + +- The WebAssembly memory needs 2.1 GB of address space. If the browser does + not give it, the page says so. Close other apps and tabs. +- System link is between copies of the web build in one room ("Online + play"), not with Xboxes or the other ports on the local network: browsers + have no UDP. +- Bink video is not available. The game skips the movies. +- Lens flares show through walls (see "WebGL 2"). +- Performance depends on the device. The game draws 480 lines at the shape of + the screen and scales them up. + +## Find problems + +*Settings and data* > *Show log* shows the page's log and `debug.txt`, the +game's log, and can copy them for a report. *Log graphics errors* sets +`debug.gl_debug`. When the game stops, the page shows why. diff --git a/port/web/opaque-canvas.js b/port/web/opaque-canvas.js new file mode 100644 index 0000000000..c1c892cc40 --- /dev/null +++ b/port/web/opaque-canvas.js @@ -0,0 +1,13 @@ +"use strict"; + +// Xbox rendering often masks alpha writes. Those frames are still fully opaque +// on an Xbox display, but a transparent WebGL canvas hides their RGB on the web. +// Choose an opaque drawing buffer before SDL/Emscripten creates the context. +(() => { + const canvas = document.getElementById("canvas"); + const getContext = canvas.getContext.bind(canvas); + canvas.getContext = (type, options) => getContext(type, + /^(webgl2?|experimental-webgl)$/.test(type) + ? { ...options, alpha: false } + : options); +})(); diff --git a/port/web/performance.js b/port/web/performance.js new file mode 100644 index 0000000000..d156301fed --- /dev/null +++ b/port/web/performance.js @@ -0,0 +1,67 @@ +"use strict"; + +// Count actual engine presents, not browser animation callbacks. +(() => { + const canvas = document.getElementById("canvas"); + const params = new URLSearchParams(location.search); + if (params.has("pixel_ratio")) { + const ratio = Math.max(0.25, Math.min(2, Number(params.get("pixel_ratio")) || 1)); + Object.defineProperty(window, "devicePixelRatio", { get: () => ratio, configurable: true }); + } + const calls = {}; + if (params.has("profile")) { + const getContext = canvas.getContext.bind(canvas); + let wrapped = false; + canvas.getContext = (type, options) => { + const gl = getContext(type, options); + if (gl && !wrapped && /webgl/.test(type)) { + wrapped = true; + const names = new Set([...Object.getOwnPropertyNames(WebGLRenderingContext.prototype), ...Object.getOwnPropertyNames(WebGL2RenderingContext.prototype)]); + for (const name of names) { + if (name === "constructor" || typeof gl[name] !== "function") continue; + const call = gl[name].bind(gl); + calls[name] = { count: 0, ms: 0 }; + gl[name] = (...args) => { + const begin = performance.now(); + const result = call(...args); + calls[name].count++; + calls[name].ms += performance.now() - begin; + if (name === "uniform4fv") { + const key = args[0] ? `length${args[1].length}` : "null"; + calls[name][key] = (calls[name][key] || 0) + 1; + } + return result; + }; + } + } + return gl; + }; + } + const hud = document.getElementById("hud"); + const output = document.createElement("output"); + output.id = "performance-stats"; + output.style.cssText = "padding:4px 10px;font:13px monospace;background:#11161ddd;border-radius:8px"; + output.textContent = "FPS —"; + hud.prepend(output); + hud.style.opacity = "0.85"; + let last = performance.now(), frames = 0; + const samples = []; + setInterval(() => { + const now = performance.now(); + const presented = window.halo?.haloFramesPresented || 0; + const fps = (presented - frames) * 1000 / (now - last); + if (presented && !document.hidden) { + output.textContent = `${fps.toFixed(1)} FPS`; + output.title = `${canvas.width} × ${canvas.height} canvas`; + samples.push({ fps: +fps.toFixed(2), frames: presented, ms: Math.round(now), width: canvas.width, height: canvas.height }); + if (samples.length > 120) samples.shift(); + output.dataset.samples = JSON.stringify(samples); + if (params.has("profile")) { + output.dataset.calls = JSON.stringify(calls); + for (const call of Object.values(calls)) for (const key of Object.keys(call)) call[key] = 0; + } + } + frames = presented; + last = now; + }, 1000); +})(); diff --git a/port/web/replay-cache.js b/port/web/replay-cache.js new file mode 100644 index 0000000000..b8969b19e6 --- /dev/null +++ b/port/web/replay-cache.js @@ -0,0 +1,228 @@ +"use strict"; + +// Load AFTER stream-batch.js to suppress redundant state changes before their +// arguments are copied and queued. Use ?replay_cache=0 to disable for comparison. +// No buffer uploads or draw commands are moved, combined, or suppressed here. +(() => { + if (new URLSearchParams(location.search).get("replay_cache") === "0") return; + const canvas = document.getElementById("canvas"); + const getContext = canvas.getContext.bind(canvas); + let wrapped = false; + canvas.getContext = (type, options) => { + const gl = getContext(type, options); + if (!gl || wrapped || type !== "webgl2") return gl; + wrapped = true; + const native = {}; + const methods = new Set([ + ...Object.getOwnPropertyNames(WebGLRenderingContext.prototype), + ...Object.getOwnPropertyNames(WebGL2RenderingContext.prototype), + ]); + for (const name of methods) + if (name !== "constructor" && typeof gl[name] === "function") native[name] = gl[name].bind(gl); + const stats = { uniformCalls: 0, uniformSkipped: 0, uniformBytesSkipped: 0, + stateCalls: 0, stateSkipped: 0, attributeCalls: 0, attributeSkipped: 0 }; + let programs, locations, scalars, capabilities, constants, samplerValues; + let currentProgram; + const reset = () => { + programs = new WeakMap(); locations = new WeakMap(); samplerValues = new WeakMap(); + scalars = new Map(); capabilities = new Map(); constants = new Map(); + currentProgram = undefined; + }; + reset(); + const same = (a, b) => { + if (!a || a.length !== b.length) return false; + for (let index = 0; index < b.length; index++) + if (!Object.is(a[index], b[index])) return false; + return true; + }; + const stateCall = (key, values, method, args) => { + stats.stateCalls++; + if (same(scalars.get(key), values)) { stats.stateSkipped++; return; } + native[method](...args); + scalars.set(key, values); + }; + // These states have exactly one setter each. Setter pairs which affect + // overlapping state are handled together below or left uncached. + for (const name of ["depthFunc", "depthMask", "depthRange", "cullFace", "frontFace", + "colorMask", "viewport", "scissor", "polygonOffset", "clearColor", "clearDepth", + "clearStencil", "lineWidth", "blendColor", "sampleCoverage", "activeTexture"]) + if (native[name]) gl[name] = (...args) => stateCall(name, args, name, args); + for (const name of ["enable", "disable"]) if (native[name]) gl[name] = capability => { + stats.stateCalls++; + const value = name === "enable"; + if (capabilities.get(capability) === value) { stats.stateSkipped++; return; } + native[name](capability); capabilities.set(capability, value); + }; + if (native.blendFunc) gl.blendFunc = (source, destination) => + stateCall("blendFactors", [source, destination, source, destination], "blendFunc", [source, destination]); + if (native.blendFuncSeparate) gl.blendFuncSeparate = (...args) => stateCall("blendFactors", args, "blendFuncSeparate", args); + if (native.blendEquation) gl.blendEquation = mode => stateCall("blendEquations", [mode, mode], "blendEquation", [mode]); + if (native.blendEquationSeparate) gl.blendEquationSeparate = (...args) => stateCall("blendEquations", args, "blendEquationSeparate", args); + + gl.useProgram = program => { + // A link/delete can change whether useProgram succeeds, so those + // operations invalidate this cache even if the object is unchanged. + stateCall("useProgram", [program], "useProgram", [program]); + currentProgram = program; + }; + const splitName = name => { + const match = /^(\w+)(?:\[(\d+)\])?$/.exec(name); + return match ? { base: match[1], index: Number(match[2] || 0) } : null; + }; + const inspectProgram = program => { + const record = { groups: new Map(), live: true }; + programs.set(program, record); + if (!native.getProgramParameter(program, gl.LINK_STATUS)) return record; + const count = native.getProgramParameter(program, gl.ACTIVE_UNIFORMS); + for (let index = 0; index < count; index++) { + const info = native.getActiveUniform(program, index); + const name = info && splitName(info.name); + // Halo uses plain float/vec4 arrays and scalar int samplers. + // Unknown shapes remain on the untouched native path. + const width = info?.type === gl.FLOAT_VEC4 ? 4 : info?.type === gl.FLOAT ? 1 : 0; + const integer = info && [gl.INT, gl.BOOL, gl.SAMPLER_2D, gl.SAMPLER_CUBE, gl.SAMPLER_3D, + gl.SAMPLER_2D_ARRAY, gl.SAMPLER_2D_SHADOW, gl.SAMPLER_CUBE_SHADOW, + gl.INT_SAMPLER_2D, gl.UNSIGNED_INT_SAMPLER_2D].includes(info.type); + if (!name || (!width && !integer)) continue; + const components = width || 1; + record.groups.set(name.base, { width: components, integer, size: info.size, + values: integer ? new Int32Array(info.size * components) : new Float32Array(info.size * components), + valid: new Uint8Array(info.size * components) }); + } + return record; + }; + gl.linkProgram = program => { + const old = programs.get(program); + if (old) old.live = false; + scalars.delete("useProgram"); + native.linkProgram(program); + inspectProgram(program); + }; + gl.deleteProgram = program => { + const old = program && programs.get(program); + if (old) old.live = false; + if (program) programs.delete(program); + scalars.delete("useProgram"); + return native.deleteProgram(program); + }; + gl.getUniformLocation = (program, name) => { + const location = native.getUniformLocation(program, name); + const parsed = splitName(name), record = program && programs.get(program); + const group = parsed && record?.groups.get(parsed.base); + if (location && group && parsed.index < group.size) + locations.set(location, { program, record, group, start: parsed.index * group.width }); + return location; + }; + const entryFor = location => { + const entry = location && locations.get(location); + return entry?.record.live && entry.program === currentProgram ? entry : null; + }; + const invalidate = location => { + const entry = location && locations.get(location); + if (entry?.record.live) entry.group.valid.fill(0); + }; + // Track every component of a uniform array, not just its location or + // last argument list: c[0] + count can overlap later c[17] uploads. + const upload = (name, location, data, offset, count, args, width, integer) => { + stats.uniformCalls++; + if (location === null) { stats.uniformSkipped++; return; } + const entry = entryFor(location); + const end = entry ? entry.start + count : 0; + if (!entry || entry.group.width !== width || !!entry.group.integer !== integer || + !Number.isInteger(offset) || !Number.isInteger(count) || offset < 0 || count <= 0 || + count % width || offset + count > data.length || end > entry.group.values.length) { + invalidate(location); + return native[name](...args); + } + const { group, start } = entry; + // Emscripten's Float32Array already contains rounded float32 values. + // Other realms/ordinary arrays use the original conversion path. + const float32 = !integer && data instanceof Float32Array; + let equal = true; + for (let index = 0; index < count; index++) { + const value = float32 ? data[offset + index] : integer ? data[offset + index] | 0 : Math.fround(data[offset + index]); + if (!group.valid[start + index] || !Object.is(group.values[start + index], value)) { equal = false; break; } + } + if (equal) { stats.uniformSkipped++; stats.uniformBytesSkipped += count * 4; return; } + native[name](...args); + for (let index = 0; index < count; index++) { + group.values[start + index] = float32 ? data[offset + index] : integer ? data[offset + index] | 0 : Math.fround(data[offset + index]); + group.valid[start + index] = 1; + } + }; + for (const name of ["uniform4fv", "uniform1fv", "uniform1iv"]) if (native[name]) gl[name] = (...args) => { + const [location, data, offset = 0, length = 0] = args; + if (!data || !Number.isInteger(data.length)) { invalidate(location); return native[name](...args); } + upload(name, location, data, offset, length || data.length - offset, args, name === "uniform4fv" ? 4 : 1, name === "uniform1iv"); + }; + for (const name of ["uniform1f", "uniform1i", "uniform4f"]) if (native[name]) gl[name] = (...args) => + upload(name, args[0], args.slice(1), 0, args.length - 1, args, name === "uniform4f" ? 4 : 1, name === "uniform1i"); + for (const name of methods) if (/^uniform(?:[1234][fiu]v?|Matrix\w+fv)$/.test(name) && native[name] && + !["uniform4fv", "uniform1fv", "uniform1iv", "uniform1f", "uniform1i", "uniform4f"].includes(name)) + gl[name] = (...args) => { invalidate(args[0]); return native[name](...args); }; + + // Keep the common repeated FLOAT constant-attribute path allocation-free. + // This retains Object.is semantics for NaN and signed zero. + const sameConstant = (index, x, y, z, w) => { + const value = constants.get(index); + return value && Object.is(value[0], x) && Object.is(value[1], y) && + Object.is(value[2], z) && Object.is(value[3], w); + }; + const rememberConstant = (index, x, y, z, w) => { + let value = constants.get(index); + if (!value) { value = [0, 0, 0, 1]; constants.set(index, value); } + value[0] = x; value[1] = y; value[2] = z; value[3] = w; + }; + for (const name of methods) { + const match = /^vertexAttrib([1234])(f|fv)$/.exec(name); + if (!match || !native[name]) continue; + const width = Number(match[1]), vector = match[2] === "fv"; + if (vector) gl[name] = (index, data) => { + stats.attributeCalls++; + if (!data || data.length < width) { constants.delete(index); return native[name](index, data); } + const float32 = data instanceof Float32Array; + const x = float32 ? data[0] : Math.fround(data[0]); + const y = width > 1 ? float32 ? data[1] : Math.fround(data[1]) : 0; + const z = width > 2 ? float32 ? data[2] : Math.fround(data[2]) : 0; + const w = width > 3 ? float32 ? data[3] : Math.fround(data[3]) : 1; + if (sameConstant(index, x, y, z, w)) { stats.attributeSkipped++; return; } + native[name](index, data); + rememberConstant(index, x, y, z, w); + }; + else gl[name] = (index, a, b, c, d) => { + stats.attributeCalls++; + const x = Math.fround(a), y = width > 1 ? Math.fround(b) : 0; + const z = width > 2 ? Math.fround(c) : 0, w = width > 3 ? Math.fround(d) : 1; + if (sameConstant(index, x, y, z, w)) { stats.attributeSkipped++; return; } + if (width === 1) native[name](index, a); + else if (width === 2) native[name](index, a, b); + else if (width === 3) native[name](index, a, b, c); + else native[name](index, a, b, c, d); + rememberConstant(index, x, y, z, w); + }; + } + for (const name of ["vertexAttribI4i", "vertexAttribI4ui", "vertexAttribI4iv", "vertexAttribI4uiv"]) + if (native[name]) gl[name] = (...args) => { constants.delete(args[0]); return native[name](...args); }; + for (const name of ["samplerParameteri", "samplerParameterf"]) if (native[name]) gl[name] = (sampler, pname, value) => { + stats.stateCalls++; + if (!sampler) return native[name](sampler, pname, value); + let parameters = samplerValues.get(sampler); + if (!parameters) samplerValues.set(sampler, parameters = new Map()); + // Keep setter kinds separate: integer and float conversion can differ. + const previous = parameters.get(pname); + if (previous?.name === name && Object.is(previous.value, value)) { stats.stateSkipped++; return; } + native[name](sampler, pname, value); parameters.set(pname, { name, value }); + }; + if (native.deleteSampler) gl.deleteSampler = sampler => { + if (sampler) samplerValues.delete(sampler); + return native.deleteSampler(sampler); + }; + canvas.addEventListener("webglcontextlost", reset); + canvas.addEventListener("webglcontextrestored", reset); + setInterval(() => { + const output = document.getElementById("performance-stats"); + if (output) output.dataset.replayCache = JSON.stringify(stats); + }, 1000); + return gl; + }; +})(); diff --git a/port/web/site/app.js b/port/web/site/app.js new file mode 100644 index 0000000000..4067690010 --- /dev/null +++ b/port/web/site/app.js @@ -0,0 +1,1379 @@ +/* +APP.JS + +The page of the web port (port/web/README.md): it checks that the browser +can run the game, copies the game data out of the player's disc image +(xiso-worker.js), then starts the game (halo.js and halo.wasm, built by +`ninja web`) and serves it on the main thread: + +- each frame the game's thread posts as an ImageBitmap or RGBA pixel buffer + goes onto the canvas (Module.haloPresent); +- each animation frame advances a counter the game waits on, polls the + controllers (input.js) and gives the game the canvas's size; +- the game's sound plays through an AudioWorklet (audio-worklet.js). +*/ + +'use strict'; + +(() => { + const $ = (id) => document.getElementById(id); + // the WebAssembly memory the build expects (tools/web_build.py + // WEB_MEMORY_BYTES): the Xbox memory window ends at 0x88000000 + const MEMORY_PAGES = 0x88000000 / 65536; + const REQUIRED_BYTES = 2.1e9; + const DEFAULT_ROOM = window.HALO_BROWSER_CONFIG?.defaultRoom ?? 'FQLX01'; + const QUICK_MAPS = ['ui.map', 'beavercreek.map']; + const diagnosticOptions = new URLSearchParams(location.search); + const ios = /iPhone|iPad|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const pixelFrames = diagnosticOptions.get('frame_transport') === 'rgba' || + (diagnosticOptions.get('frame_transport') !== 'bitmap' && + /Macintosh/.test(navigator.userAgent) && /Chrome|Chromium|Edg\//.test(navigator.userAgent)); + + const state = { + memory: null, + audio: null, + started: false, + shared: null, + offsets: null, + log: [], + wakeLock: null, + version: null, + invite: null, + gateway: null, + gatewayInstalled: false, + inviteConnecting: false, + inviteAttempt: 0, + nativeInstalling: false, + pendingInviteConnect: null, + roomTask: Promise.resolve(), + maps: null, + dataBusy: false, + dataTransition: false, + releaseGameLock: null, + checksReady: false, + manualMode: new URLSearchParams(location.search).get('menu') === '1', + manualRequested: false, + selectedRoom: null, + quickController: null, + quickFailed: false, + quickRole: null, + quickPhase: null, + audioStarted: false, + roomSettingsOpen: false, + }; + + // ---------- settings (this browser's; nothing else depends on them) + + const coarsePointer = matchMedia('(pointer: coarse)').matches; + const settings = { touch: coarsePointer, touchFps: false, look: 1.4, vsync: true, glDebug: false, renderHeight: ios || pixelFrames ? 480 : 720 }; + try { + Object.assign(settings, JSON.parse(localStorage.getItem('halo-web-settings') || '{}')); + } catch { /* private browsing: the defaults */ } + if (![480, 720, 1080, 1440].includes(Number(settings.renderHeight))) settings.renderHeight = ios || pixelFrames ? 480 : 720; + + function saveSettings() { + try { localStorage.setItem('halo-web-settings', JSON.stringify(settings)); } catch { /* not kept */ } + } + + // ---------- log + + function log(line) { + const text = String(line); + state.log.push(text); + if (state.log.length > 2000) state.log.splice(0, state.log.length - 2000); + console.log(text); + } + + async function debugFile() { + try { + // WasmFS mounts OPFS at /data; reused Apollo maps run from /data/halo/data. + const directories = (state.maps?.dataRoot || '/data').split('/').filter(Boolean); + if (directories.shift() !== 'data') return ''; + let root = await navigator.storage.getDirectory(); + for (const directory of directories) root = await root.getDirectoryHandle(directory); + return await (await root.getFileHandle('debug.txt')).getFile(); + } catch { + return null; + } + } + + async function debugText() { + const file = await debugFile(); + if (!file) return ''; + if (!file.stream || file.size <= 200000 || file.size === undefined) return file.text(); + // Scan the entire file in bounded chunks. Timeout spam must not erase + // the earlier disconnect/adoption receipts needed to diagnose a match. + const reader = file.stream().getReader(), decoder = new TextDecoder(); + let carry = '', head = '', tail = '', important = '', timeouts = 0, firstTimeout = '', lastTimeout = ''; + const collect = line => { + if (head.length < 5000) head = (head + line + '\n').slice(0, 5000); + tail = (tail + line + '\n').slice(-50000); + if (line.includes('timeout in network_connection_idle')) { + if (!timeouts) firstTimeout = line; + lastTimeout = line; timeouts++; + } else if (!line.includes('network_connection_idle() failed in network_game_client_idle_ingame()') && + /migration|reattach|adopted the live match|checkpoint|epoch|out.of.sync|connection.*(?:fail|lost|timed|went)|machine.*(?:remov|accept|connect)/i.test(line)) { + important = (important + line + '\n').slice(-140000); + } + }; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + carry += decoder.decode(value, { stream: true }); + let end; + while ((end = carry.indexOf('\n')) >= 0) { collect(carry.slice(0, end)); carry = carry.slice(end + 1); } + if (carry.length > 200000) carry = carry.slice(-200000); + } + carry += decoder.decode(); + if (carry) collect(carry); + } finally { reader.releaseLock(); } + return `Scanned ${file.size} bytes; selected connection events and recent tail below.\n` + + `--- start ---\n${head}\n--- connection events ---\n${important}` + + (timeouts ? `\nConnection idle timeouts: ${timeouts}\nFirst: ${firstTimeout}\nLast: ${lastTimeout}\n` : '') + + `\n--- recent tail ---\n${tail}`; + } + + async function fullLog() { + const debug = await debugText(); + return `${navigator.userAgent}\n\n--- page and console ---\n${state.log.join('\n')}` + + (debug ? `\n\n--- debug.txt ---\n${debug}` : ''); + } + + function toast(text, milliseconds = 3500) { + const element = $('toast'); + element.textContent = text; + element.hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { element.hidden = true; }, milliseconds); + } + + async function fatal(text) { + log('fatal: ' + text); + $('fatal-text').textContent = text; + $('fatal').hidden = false; + document.body.classList.remove('playing'); + } + + // ---------- checks + + function addCheck(ok, text, level) { + const element = document.createElement('div'); + element.className = 'check' + (ok ? '' : level === 'warn' ? ' warn' : ' bad'); + element.textContent = text; + $('checks').appendChild(element); + return ok; + } + + async function ensureIsolation() { + if (!('serviceWorker' in navigator)) return; + try { + await navigator.serviceWorker.register('sw.js'); + } catch (error) { + log('service worker: ' + error); + return; + } + if (window.crossOriginIsolated) return; + await navigator.serviceWorker.ready; + // the service worker serves the page with the isolation headers from + // its next load on + let reloaded = false; + try { reloaded = sessionStorage.getItem('halo-web-isolation') === '1'; } catch { /* none */ } + if (!reloaded) { + try { sessionStorage.setItem('halo-web-isolation', '1'); } catch { /* none */ } + location.reload(); + await new Promise(() => {}); + } + } + + function webgl2InWorkers() { + try { + return typeof OffscreenCanvas !== 'undefined' && !!new OffscreenCanvas(1, 1).getContext('webgl2'); + } catch { + return false; + } + } + + async function checkGameStorage() { + if (!navigator.storage?.getDirectory) { + return addCheck(false, 'Game storage is unavailable in this browser. Open this page in an up-to-date Safari or Chrome.'); + } + try { + // Safari exposes this API even when it refuses OPFS access (for + // example in Private Browsing). Test access before reserving memory. + await navigator.storage.getDirectory(); + return addCheck(true, 'Game file storage (OPFS)'); + } catch (error) { + log('game storage: ' + (error?.stack || error)); + return addCheck(false, 'Game storage could not be opened. In Safari, open this link in a regular tab ' + + 'instead of Private Browsing. If you are already in a regular tab, close other Halo tabs and restart Safari.'); + } + } + + async function runChecks() { + let ok = true; + ok = addCheck(window.crossOriginIsolated && typeof SharedArrayBuffer !== 'undefined', + window.crossOriginIsolated ? 'Threads (SharedArrayBuffer)' : + 'Threads: this page is not cross-origin isolated. Reload it; if this stays, the browser is too old.') && ok; + ok = addCheck(webgl2InWorkers(), 'WebGL 2 from a worker (OffscreenCanvas; iOS 17 or later)') && ok; + ok = await checkGameStorage() && ok; + // Reserve the Xbox address window only after Play owns the game lock. + // Downloads, idle launchers and a second tab need no game-sized memory. + ok = addCheck(typeof WebAssembly !== 'undefined' && typeof WebAssembly.Memory === 'function', 'WebAssembly memory') && ok; + if (navigator.storage && navigator.storage.estimate) { + try { + const estimate = await navigator.storage.estimate(); + const free = (estimate.quota || 0) - (estimate.usage || 0); + $('storage-summary').textContent = `Storage: ${(estimate.usage / 1e9).toFixed(2)} GB used of ` + + `${(estimate.quota / 1e9).toFixed(1)} GB this site may use.`; + state.freeBytes = free; + } catch { /* unknown */ } + } + return ok; + } + + // ---------- game data + + function requiredMaps() { + // Desktop invites can name any map. Only browser quick play fixes the + // match to Beaver Creek; the full menu retains all supported scenarios. + return !state.manualMode && state.selectedRoom && !state.invite ? QUICK_MAPS : HaloCache.expected; + } + + function hasFullMaps() { + return !!state.maps && HaloCache.expected.every(name => state.maps.files.includes(name)); + } + + async function mapsState() { + return HaloCache.mapsState({ required: requiredMaps() }); + } + + function updatePlayButton() { + const automatic = !state.manualMode && !!(state.invite || state.selectedRoom); + $('play').hidden = automatic; + $('play').textContent = 'Play main menu'; + $('play').disabled = state.started || (!automatic && (!state.maps || state.dataBusy)); + } + + function showSteps(maps) { + state.maps = maps; + $('step-data').hidden = !!maps; + $('step-play').hidden = !maps; + if (maps) { + $('data-summary').textContent = `Game data: ${maps.files.length} maps, ${(maps.bytes / 1e9).toFixed(2)} GB.`; + } + updatePlayButton(); + connectPendingInvite(); + maybeQuickPlay(); + } + + function connectPendingInvite() { + if (hasFullMaps() && !state.dataBusy && state.pendingInviteConnect && !state.dataTransition) { + const connect = state.pendingInviteConnect; + state.pendingInviteConnect = null; + connect(); + } + } + + function setDataBusy(busy) { + state.dataBusy = busy; + $('iso-file').disabled = busy; + $('delete-data').disabled = busy; + updatePlayButton(); + connectPendingInvite(); + maybeQuickPlay(); + } + + function extract(file) { + return new Promise((resolve, reject) => { + const worker = new Worker('xiso-worker.js'); + const started = Date.now(); + $('progress').hidden = false; + worker.onmessage = (event) => { + const message = event.data; + if (message.type === 'progress') { + const fraction = message.total ? message.done / message.total : 0; + $('progress-fill').style.width = (fraction * 100).toFixed(1) + '%'; + const seconds = (Date.now() - started) / 1000; + const rate = message.done / Math.max(seconds, 0.1); + const left = rate > 0 ? (message.total - message.done) / rate : 0; + $('progress-text').textContent = `Copying maps/${message.file}: ` + + `${(message.done / 1e9).toFixed(2)} of ${(message.total / 1e9).toFixed(2)} GB` + + (seconds > 3 ? `, about ${Math.ceil(left / 60)} min left` : ''); + } else if (message.type === 'done') { + worker.terminate(); + resolve(message); + } else if (message.type === 'error') { + worker.terminate(); + reject(new Error(message.message)); + } + }; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message || 'The copy stopped.')); + }; + worker.postMessage({ file }); + }); + } + + async function onImageChosen(event) { + const file = event.target.files && event.target.files[0]; + event.target.value = ''; + if (!file) return; + if (state.freeBytes !== undefined && state.freeBytes < REQUIRED_BYTES) { + toast('There may not be enough free storage for the game data (about 1.8 GB).', 6000); + } + if (navigator.storage && navigator.storage.persist) { + // keep the data when the device runs low on space + navigator.storage.persist().catch(() => {}); + } + setDataBusy(true); + try { + const result = await HaloCache.withLock(() => extract(file)); + log(`extracted ${result.files} files, ${result.bytes} bytes`); + showSteps(await mapsState()); + $('progress-text').textContent = state.maps ? 'Done. Maps are ready to play.' : + 'This disc image does not contain the maps needed for this mode.'; + } catch (error) { + $('progress-text').textContent = error.message; + log('extraction failed: ' + error.message); + } finally { + setDataBusy(false); + } + } + + // ---------- saved games: a .zip of the save folder (stored, not compressed) + + const crcTable = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; + })(); + + function crc32(bytes) { + let crc = 0xFFFFFFFF; + for (let i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8); + return (crc ^ 0xFFFFFFFF) >>> 0; + } + + function zip(files) { + const encoder = new TextEncoder(); + const parts = []; + const central = []; + let offset = 0; + for (const { name, bytes } of files) { + const nameBytes = encoder.encode(name); + const crc = crc32(bytes); + const local = new DataView(new ArrayBuffer(30)); + local.setUint32(0, 0x04034b50, true); + local.setUint16(4, 20, true); + local.setUint32(14, crc, true); + local.setUint32(18, bytes.length, true); + local.setUint32(22, bytes.length, true); + local.setUint16(26, nameBytes.length, true); + parts.push(new Uint8Array(local.buffer), nameBytes, bytes); + const entry = new DataView(new ArrayBuffer(46)); + entry.setUint32(0, 0x02014b50, true); + entry.setUint16(4, 20, true); + entry.setUint16(6, 20, true); + entry.setUint32(16, crc, true); + entry.setUint32(20, bytes.length, true); + entry.setUint32(24, bytes.length, true); + entry.setUint16(28, nameBytes.length, true); + entry.setUint32(42, offset, true); + central.push(new Uint8Array(entry.buffer), nameBytes); + offset += 30 + nameBytes.length + bytes.length; + } + const centralSize = central.reduce((sum, part) => sum + part.length, 0); + const end = new DataView(new ArrayBuffer(22)); + end.setUint32(0, 0x06054b50, true); + end.setUint16(8, files.length, true); + end.setUint16(10, files.length, true); + end.setUint32(12, centralSize, true); + end.setUint32(16, offset, true); + return new Blob([...parts, ...central, new Uint8Array(end.buffer)], { type: 'application/zip' }); + } + + async function exportSaves() { + const files = []; + async function walk(directory, prefix) { + for await (const [name, handle] of directory.entries()) { + if (handle.kind === 'directory') await walk(handle, prefix + name + '/'); + else files.push({ name: prefix + name, bytes: new Uint8Array(await (await handle.getFile()).arrayBuffer()) }); + } + } + try { + const maps = state.maps || await mapsState(); + const savePath = (maps?.saveRoot || '/data/save').split('/').slice(2); + await walk(await HaloCache.directory(savePath), 'save/'); + try { + const dataPath = (maps?.dataRoot || '/data').split('/').slice(2); + const config = await (await (await HaloCache.directory(dataPath)).getFileHandle('config.toml')).getFile(); + files.push({ name: 'config.toml', bytes: new Uint8Array(await config.arrayBuffer()) }); + } catch { /* none yet */ } + } catch { + toast('There are no saved games yet.'); + return; + } + const link = document.createElement('a'); + link.href = URL.createObjectURL(zip(files)); + link.download = 'halo-saves.zip'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(link.href), 60000); + } + + async function deleteData() { + if (state.dataBusy || state.started) return; + if (!confirm('Delete the game data (the maps folder)? Saved games are kept.')) return; + try { + await HaloCache.withLock(async () => { + for (const path of [[], ['halo', 'data']]) { + let folder; + try { folder = await HaloCache.directory(path); } + catch (error) { if (error.name === 'NotFoundError') continue; throw error; } + for (const name of ['maps', 'maps.json']) { + try { await folder.removeEntry(name, { recursive: true }); } + catch (error) { if (error.name !== 'NotFoundError') throw error; } + } + } + }); + } catch (error) { toast(error.message, 6000); return; } + showSteps(await mapsState()); + setDataBusy(false); + } + + // ---------- the running game + + function quickStatus(phase, message) { + state.quickPhase = phase; + $('quick-status').textContent = message; + $('quick-game-status').textContent = message; + $('quick-panel').dataset.state = phase; + $('quick-panel').hidden = !state.started || phase === 'playing' || phase === 'menu'; + $('quick-retry').hidden = phase !== 'error' || state.started; + } + + function cancelQuickPlay() { + state.quickController?.abort(); + state.quickController = null; + HaloNet.cancelQuickPlay(); + } + + function showRoomSettings(open) { + state.roomSettingsOpen = open; + document.body.classList[open ? 'add' : 'remove']('room-settings'); + $('room-toggle').hidden = open || !state.started || !!state.invite; + $('room-close').hidden = !open; + HaloInput.setUIActive(open); + if (open && document.pointerLockElement) document.exitPointerLock(); + } + + async function maybeQuickPlay() { + if (!state.checksReady || state.started || state.dataBusy || state.dataTransition || !state.maps) return; + if (state.manualMode) { + if (state.manualRequested) { + state.manualRequested = false; + await play({ userGesture: false }); + } + return; + } + if (state.quickController || state.quickFailed) return; + if (state.invite) { + if (!hasFullMaps()) return; + if (!state.gatewayInstalled || !state.gateway?.connected) { + quickStatus('waiting', 'Waiting for the invited host…'); + return; + } + state.quickRole = 'join'; + quickStatus('joining', 'Joining the invited match…'); + await play({ role: 'join', target: state.gateway.hostAddress }); + return; + } + const room = HaloNet.status().room; + if (!room || room !== state.selectedRoom) return; + const controller = new AbortController(); + state.quickController = controller; + quickStatus('waiting', 'Connecting to the room and finding a match…'); + try { + const selection = await HaloNet.quickPlay({ signal: controller.signal, onStatus(status) { + if (controller.signal.aborted) return; + log(`network ${new Date().toISOString()}: ${status.state}: ${status.message}`); + if (typeof status.hold === 'boolean') window.Module?._web_quick_play_hold?.(status.hold ? 1 : 0); + if (!controller.signal.aborted && (!state.started || ['recovering', 'reconnecting', 'error'].includes(status.state))) + quickStatus(status.state, status.message); + }, onReconnect(selection) { + if (controller.signal.aborted || state.manualMode || state.invite || !state.started || + selection.room !== state.selectedRoom) return; + const reconnect = window.Module?._web_quick_play_reconnect; + if (!reconnect) { + quickStatus('error', 'Player reconnection needs the latest game build. Reload and choose Update.'); + return; + } + log(`network ${new Date().toISOString()}: client reattach, epoch ${selection.epoch}, host ${HaloNet.addressText(selection.hostAddress)}`); + quickStatus('reconnecting', 'Reconnecting your player. The match continues on the current host…'); + reconnect(selection.hostAddress, selection.epoch); + }, onFailover(selection) { + if (controller.signal.aborted || state.manualMode || state.invite || + !state.started || selection.room !== state.selectedRoom) return; + log(`network ${new Date().toISOString()}: replacement ${selection.role}, epoch ${selection.epoch}, host ${HaloNet.addressText(selection.hostAddress)}`); + const migrate = window.Module?._web_quick_play_migrate; + if (!migrate) { + quickStatus('error', 'Host recovery needs the latest game build. Reload and choose Update.'); + cancelQuickPlay(); + return; + } + state.quickRole = selection.role; + quickStatus('recovering', selection.role === 'host' ? + 'You are the replacement host. Preserving the match…' : 'Reconnecting to the replacement host. Preserving the match…'); + migrate(selection.role === 'host' ? 1 : 2, selection.hostAddress, selection.epoch); + } }); + if (controller.signal.aborted || state.manualMode || state.started || state.selectedRoom !== selection.room) return; + state.quickRole = selection.role; + state.quickEpoch = selection.epoch; + quickStatus(selection.role === 'host' ? 'hosting' : 'joining', selection.role === 'host' ? + 'Starting a match. Keep this game open so others can join.' : 'Joining the room’s match…'); + await play({ role: selection.role, target: selection.hostAddress }); + } catch (error) { + if (controller.signal.aborted) return; + state.quickFailed = true; + cancelQuickPlay(); + quickStatus('error', error.message || 'Could not connect to a match. Retry or open the main menu.'); + } + } + + function openMainMenu() { + if (state.roomSettingsOpen) showRoomSettings(false); + const unrecoverable = !$('fatal').hidden; + state.inviteAttempt++; + state.pendingInviteConnect = null; + state.inviteConnecting = false; + state.manualMode = true; + state.manualRequested = true; + state.quickFailed = false; + cancelQuickPlay(); + quickStatus('menu', 'Main menu selected.'); + updatePlayButton(); + unlockInteraction(); + if (!hasFullMaps()) { + // The running engine owns the OPFS game lock. Reload before importing + // maps for the full menu. + state.dataTransition = true; + const url = new URL(location.href); + url.searchParams.set('menu', '1'); + url.hash = ''; + location.href = url.toString(); + return; + } + if (state.started || state.nativeInstalling) { + if (!unrecoverable && !state.nativeInstalling && window.Module?._web_quick_play_cancel) window.Module._web_quick_play_cancel(); + else { + state.dataTransition = true; + const url = new URL(location.href); + url.searchParams.set('menu', '1'); + location.href = url.toString(); + } + } else if (state.maps && !state.dataBusy) { + state.manualRequested = false; + return play({ userGesture: true }); + } else maybeQuickPlay(); + } + + function makeAudioContext() { + if (state.audio) return; + try { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + state.audio = new AudioContextClass({ sampleRate: 48000, latencyHint: 'interactive' }); + } catch (error) { log('audio context: ' + error); } + } + + function unlockInteraction(userGesture = true) { + makeAudioContext(); + state.audio?.resume().catch(() => {}); + if (state.started && userGesture) { + const root = document.documentElement; + if (root.requestFullscreen && !navigator.standalone) root.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}); + if (screen.orientation?.lock) screen.orientation.lock('landscape').catch(() => {}); + const canvas = $('screen'); + if (canvas.requestPointerLock && matchMedia('(pointer: fine)').matches) { + try { canvas.requestPointerLock()?.catch(() => {}); } catch { /* unsupported */ } + } + } + if (state.shared && !state.audioStarted) startAudio(); + } + + function landscapeSize() { + const scale = Math.min(window.devicePixelRatio || 1, 2); + const long = Math.max(window.innerWidth, window.innerHeight); + const short = Math.min(window.innerWidth, window.innerHeight); + let width = Math.round(long * scale); + let height = Math.round(short * scale); + // Bound render targets and transferred frame surfaces independently of + // Retina/4K desktop size. Settings and diagnostics can opt into more detail. + const requestedHeight = Number(diagnosticOptions.get('render_height')); + const maximumHeight = Number.isFinite(requestedHeight) && requestedHeight > 0 ? + Math.max(480, Math.min(1440, Math.round(requestedHeight))) : Number(settings.renderHeight); + if (height > maximumHeight) { + width = Math.round(width * maximumHeight / height); + height = maximumHeight; + } + return { width: width & ~1, height: height & ~1 }; + } + + function presentationStats(canvas) { + if (diagnosticOptions.get('fps') !== '1') return null; + const output = document.createElement('output'); + output.id = 'performance-stats'; + output.style.cssText = 'position:fixed;top:max(8px,env(safe-area-inset-top));' + + 'left:max(8px,env(safe-area-inset-left));z-index:20;padding:4px 8px;' + + 'font:13px monospace;background:#11161ddd;border-radius:6px;pointer-events:none'; + output.textContent = 'FPS —'; + output.title = 'Frames received from the game worker'; + output.dataset.samples = '[]'; + document.body.appendChild(output); + const samples = []; + let frames = 0, previousFrames = 0, previousTime = performance.now(); + const resetWindow = () => { + previousFrames = frames; + previousTime = performance.now(); + }; + // Neither hidden time nor queued frames delivered while hidden belong + // in a visible FPS sample. + document.addEventListener('visibilitychange', resetWindow); + setInterval(() => { + if (document.hidden || !frames) { resetWindow(); return; } + const now = performance.now(); + const windowMs = now - previousTime; + if (windowMs < 1000) return; + const fps = (frames - previousFrames) * 1000 / windowMs; + output.textContent = `${fps.toFixed(1)} FPS`; + output.title = `${canvas.width} × ${canvas.height}; frames received from the game worker`; + samples.push({ fps: +fps.toFixed(2), frames, ms: Math.round(now), + windowMs: +windowMs.toFixed(2), width: canvas.width, height: canvas.height }); + if (samples.length > 120) samples.shift(); + output.dataset.samples = JSON.stringify(samples); + previousFrames = frames; + previousTime = now; + }, 1000); + return () => { + if (!frames) previousTime = performance.now(); + frames++; + }; + } + + function sharedWord(name) { + return (state.shared + state.offsets[name]) >> 2; + } + + function readOffsets(module) { + const pointer = module._web_shared_offsets(); + const words = new Int32Array(state.memory.buffer, pointer, 36); + const names = ['size', 'eventWrite', 'eventRead', 'events', 'eventSize', 'gamepads', 'gamepadSize', + 'displayWidth', 'displayHeight', 'frameCounter', 'framesPresented', 'vsync', 'audioRate', 'audioOpen', + 'audioWrite', 'audioRead', 'audioUnderruns', 'audioRing', 'audioRingFrames', 'pageHidden', 'gameStarted', + 'eventCapacity', 'gamepadCount', 'netLocalAddress', 'netOutWrite', 'netOutRead', 'netInWrite', 'netInRead', + 'netOut', 'netOutBytes', 'netIn', 'netInBytes', 'gatewayEnabled', 'gatewayIdentifier', + 'gatewayPeers', 'gatewayPeerCount']; + const offsets = {}; + names.forEach((name, index) => { offsets[name] = words[index]; }); + // Cached older runtimes export only the original 36 offsets. Their shared + // size must include the appended table before reading its extra offsets. + const pingStart = offsets.gatewayPeers + offsets.gatewayPeerCount * 12; + if (offsets.size >= pingStart + 20 + 128 * 8) { + const pingNames = ['pingSequence', 'pingHost', 'pingEpoch', 'pingUpdated', 'pingCount', 'pingPeers', 'pingPeerCount']; + const pingWords = new Int32Array(state.memory.buffer, pointer + 36 * 4, pingNames.length); + pingNames.forEach((name, index) => { offsets[name] = pingWords[index]; }); + } + return offsets; + } + + function updateDisplaySize() { + const i32 = new Int32Array(state.memory.buffer); + const { width, height } = landscapeSize(); + Atomics.store(i32, sharedWord('displayWidth'), width); + Atomics.store(i32, sharedWord('displayHeight'), height); + $('rotate').hidden = !(state.started && window.innerHeight > window.innerWidth); + } + + function animationFrame() { + const i32 = new Int32Array(state.memory.buffer); + Atomics.add(i32, sharedWord('frameCounter'), 1); + Atomics.notify(i32, sharedWord('frameCounter')); + HaloInput.pollGamepads(); + requestAnimationFrame(animationFrame); + } + + function onVisibility() { + if (!state.shared) return; + const i32 = new Int32Array(state.memory.buffer); + const hidden = document.hidden ? 1 : 0; + Atomics.store(i32, sharedWord('pageHidden'), hidden); + Atomics.notify(i32, sharedWord('frameCounter')); + if (state.audio) { + if (hidden) state.audio.suspend().catch(() => {}); + else state.audio.resume().catch(() => {}); + } + if (!hidden) requestWakeLock(); + } + + async function requestWakeLock() { + try { + if (navigator.wakeLock && !document.hidden) state.wakeLock = await navigator.wakeLock.request('screen'); + } catch { /* not allowed now */ } + } + + async function startAudio() { + const context = state.audio; + if (!context || state.audioStarted) return; + state.audioStarted = true; + try { + await context.audioWorklet.addModule('audio-worklet.js'); + const i32 = new Int32Array(state.memory.buffer); + const node = new AudioWorkletNode(context, 'halo-audio', { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [2], + processorOptions: { + buffer: state.memory.buffer, + ring: state.shared + state.offsets.audioRing, + ringFrames: state.offsets.audioRingFrames, + write: state.shared + state.offsets.audioWrite, + read: state.shared + state.offsets.audioRead, + underruns: state.shared + state.offsets.audioUnderruns, + rate: 48000, + }, + }); + node.connect(context.destination); + Atomics.store(i32, sharedWord('audioOpen'), 1); + log(`audio: ${context.sampleRate} Hz`); + } catch (error) { + state.audioStarted = false; + log('audio: ' + error); + toast('Sound is not available: ' + error.message); + } + } + + async function play({ role = null, target = null, userGesture = false } = {}) { + if (state.started || state.dataBusy || state.dataTransition || !state.maps) return; + if (role && state.invite && (!state.gatewayInstalled || !state.gateway?.connected)) { + quickStatus('waiting', 'Waiting for the invited host…'); + return; + } + state.started = true; + unlockInteraction(userGesture); + try { + if (navigator.locks) { + await new Promise((resolve, reject) => { + navigator.locks.request('halo-game', { ifAvailable: true }, lock => { + if (!lock) { reject(new Error('Halo is open in another tab. Close it, then press Play.')); return; } + resolve(); + return new Promise(release => { state.releaseGameLock = release; }); + }).catch(reject); + }); + } + } catch (error) { + state.started = false; + state.audio?.close().catch(() => {}); + state.audio = null; + state.quickFailed = true; + cancelQuickPlay(); + quickStatus('error', error.message); + updatePlayButton(); + return; + } + if (state.dataTransition) { + state.releaseGameLock?.(); + state.releaseGameLock = null; + state.started = false; + return; + } + try { + state.memory = new WebAssembly.Memory({ initial: MEMORY_PAGES, maximum: MEMORY_PAGES, shared: true }); + } catch (error) { + state.releaseGameLock?.(); + state.releaseGameLock = null; + state.started = false; + state.audio?.close().catch(() => {}); + state.audio = null; + state.quickFailed = true; + cancelQuickPlay(); + quickStatus('error', 'The browser could not reserve memory for Halo. Close other Halo tabs or apps and retry.'); + log('memory: ' + (error?.message || error)); + updatePlayButton(); + return; + } + updatePlayButton(); + document.body.classList.add('playing'); + $('room-toggle').hidden = !!state.invite; + $('quick-panel').hidden = !role; + requestWakeLock(); + // the system's back gesture or button (Android) backs out of menus, as + // the controller's B does, instead of leaving the game + history.pushState({ playing: true }, ''); + window.addEventListener('popstate', () => { + HaloInput.pressBack(); + history.pushState({ playing: true }, ''); + }); + + const canvas = $('screen'); + const context = canvas.getContext(pixelFrames ? '2d' : 'bitmaprenderer', { alpha: false }); + const countPresent = presentationStats(canvas); + // (tests pass extra --NAME=value settings in window.__haloArgs) + const argumentsList = Array.isArray(window.__haloArgs) ? window.__haloArgs.slice() : []; + argumentsList.push('--HALO_DATA_ROOT=' + state.maps.dataRoot, '--HALO_SAVE_ROOT=' + state.maps.saveRoot); + argumentsList.push('--HALO_WEB_PRESENT_ACK=1'); + if (pixelFrames) argumentsList.push('--HALO_WEB_PIXEL_FRAMES=1'); + if (role) { + argumentsList.push('--HALO_QUICK_PLAY=' + role); + if (role === 'join' && target) argumentsList.push('--HALO_QUICK_PLAY_TARGET=' + HaloNet.addressText(target)); + } + if (diagnosticOptions.get('batch_streams') === '0') argumentsList.push('--HALO_WEB_BATCH_STREAMS=0'); + if (diagnosticOptions.get('geometry_cache') === '1') argumentsList.push('--HALO_WEB_GEOMETRY_CACHE=1'); + if (!settings.vsync) argumentsList.push('--HALO_NO_VSYNC=1'); + if (settings.glDebug) argumentsList.push('--HALO_GL_DEBUG=1'); + + window.Module = { + wasmMemory: state.memory, + arguments: argumentsList, + print: (text) => log(text), + printErr: (text) => log(text), + haloPresent: (bitmap, pendingBuffer) => { + try { + // A bitmap sent just before the tab was hidden may arrive after + // rendering stops. Release it without updating the hidden canvas. + if (document.hidden) return; + if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + } + if (bitmap.pixels) { + context.putImageData(new ImageData(new Uint8ClampedArray(bitmap.pixels), bitmap.width, bitmap.height), 0, 0); + } else if (pixelFrames) { + // An older cached runtime can still send ImageBitmaps to a newer + // launcher until its offered update is applied. + context.drawImage(bitmap, 0, 0); + } else context.transferFromImageBitmap(bitmap); + countPresent?.(); + } finally { + try { bitmap.close?.(); } + finally { + // Older runtimes send only the bitmap; newer ones bound their + // transfer queue with this shared acknowledgement counter. + if (pendingBuffer) Atomics.sub(new Int32Array(pendingBuffer), 0, 1); + } + } + }, + haloMessage: (kind, text) => { + if (kind === 0) log('game: ' + text); + else if (kind === 6) { + try { + const status = JSON.parse(text); + if (status.phase === 'checkpoint') { + HaloNet.quickPlayCheckpoint(status); + return; + } + log(`network ${new Date().toISOString()}: native ${status.phase}: ${status.message}`); + if (status.phase === 'disconnected' && !state.invite && !state.manualMode && HaloNet.quickPlayLost()) { + quickStatus('recovering', 'Restoring the connection while preserving the match…'); + return; + } + const recovering = HaloNet.quickPlayPhase(status.phase); + if (recovering) { + quickStatus('recovering', 'Choosing a replacement host and preserving the match…'); + return; + } + quickStatus(status.phase, status.message); + if (status.phase === 'menu' || status.phase === 'error' || status.phase === 'disconnected') { + state.quickFailed = status.phase === 'error'; + state.manualMode = true; + cancelQuickPlay(); + if (!hasFullMaps()) { + if (status.phase === 'menu') openMainMenu(); + else fatal(status.message + ' Reload to retry multiplayer, or choose Main menu and import your disc image.'); + } + } + } catch (error) { log('quick play status: ' + error); } + } + else if (kind === 1) toast(text, 5000); + else if (kind === 2) log('clipboard: ' + text); + else if (kind === 4) log('thread error: ' + text); + else if (kind === 5) { + log('game: ' + text); + $('fatal').querySelector('h2').textContent = 'The game quit'; + fatal(text + ' Reload to start it again.'); + } + else fatal(text); + }, + onAbort: (what) => fatal('The game stopped: ' + what), + onRuntimeInitialized: () => { + const module = window.Module; + module._web_quick_play_set_address?.(HaloNet.address); + module._web_quick_play_set_epoch?.(state.quickEpoch || 0); + state.shared = module._web_shared_state(); + state.offsets = readOffsets(module); + onVisibility(); + updateDisplaySize(); + HaloInput.attach({ + memory: state.memory, + base: state.shared, + offsets: state.offsets, + canvas, + touchRoot: $('touch'), + touch: settings.touch, + touchFps: settings.touchFps === true, + }); + HaloInput.setLookSensitivity(settings.look); + HaloNet.attach({ memory: state.memory, base: state.shared, offsets: state.offsets }); + if (role && !state.invite) HaloNet.quickPlayStarted(); + $('touch').hidden = !settings.touch; + requestAnimationFrame(animationFrame); + startAudio(); + log('runtime ready'); + }, + }; + + const script = document.createElement('script'); + script.src = 'halo.js'; + script.onerror = () => fatal('Could not load halo.js.'); + document.body.appendChild(script); + } + + // ---------- online play (net.js) + + function setUpInvites() { + if (!$('invite-input')) return; + const relay = window.HALO_BROWSER_CONFIG?.relayUrl || ''; + const linked = new URLSearchParams(location.hash.slice(1)).get('join'); + state.invite = linked ? HaloInvite.parse(linked) : null; + if (state.invite) $('invite-input').value = 'halo://join/' + state.invite; + $('invite-status').textContent = linked && !state.invite ? 'This invite is incomplete or invalid.' : + relay ? 'Ready for a desktop invite.' : 'Desktop invites need a relay. This preview does not have a relay configured yet.'; + $('relay-access-row').hidden = !relay; + const connect = async () => { + if (state.inviteConnecting || state.gatewayInstalled || state.started) return; + const token = HaloInvite.parse($('invite-input').value); + if (!token) { $('invite-status').textContent = 'Paste a complete halo://join/ invite.'; return; } + cancelQuickPlay(); + state.quickFailed = false; + state.invite = token; + $('play').disabled = true; + $('browser-rooms').hidden = true; + if (!relay) { + $('invite-status').textContent = 'The browser link is valid, but this site needs a relay before it can join desktop games.'; + return; + } + if (!hasFullMaps() || state.dataBusy) { + state.pendingInviteConnect = connect; + $('step-data').hidden = false; + $('invite-status').textContent = 'Import your disc image to join this host. The invite will connect when all maps are ready.'; + connectPendingInvite(); + return; + } + $('invite-connect').disabled = true; + $('invite-input').disabled = true; + state.inviteConnecting = true; + const attempt = ++state.inviteAttempt; + let transport = null; + try { + transport = await HaloGateway.connect(relay, token, { + accessToken: $('relay-access').value, + onStatus(status) { + if (attempt !== state.inviteAttempt) return; + $('invite-status').textContent = status.message; + if (status.state === 'error') { + if (state.started && !state.manualMode) fatal(status.message + ' Reload to start a new session.'); + else { + state.quickFailed = true; + state.gateway = null; + state.gatewayInstalled = false; + $('invite-connect').disabled = false; + $('invite-input').disabled = false; + quickStatus('error', status.message); + } + } + updatePlayButton(); + maybeQuickPlay(); + }, + }); + if (attempt !== state.inviteAttempt) { transport.close(); return; } + await state.roomTask; + if (attempt !== state.inviteAttempt) { transport.close(); return; } + state.nativeInstalling = true; + try { await HaloNet.useTransport(transport); } + finally { state.nativeInstalling = false; } + if (attempt !== state.inviteAttempt) { transport.close(); return; } + // A host failure can arrive while leaving the previous browser room. + if (transport.closed) throw new Error('The relay session ended. Try connecting again.'); + state.gateway = transport; + state.gatewayInstalled = true; + $('online-address').textContent = HaloNet.addressText(state.gateway.address); + $('invite-input').disabled = true; + $('relay-access').value = ''; + updatePlayButton(); + maybeQuickPlay(); + } catch (error) { + transport?.close(); + if (attempt !== state.inviteAttempt) return; + $('invite-status').textContent = error.message; + $('invite-connect').disabled = false; + $('invite-input').disabled = false; + } finally { + if (attempt === state.inviteAttempt) { + state.inviteConnecting = false; + updatePlayButton(); + } + } + }; + $('invite-connect').onclick = connect; + $('invite-input').onkeydown = (event) => { if (event.key === 'Enter') connect(); }; + $('invite-share').onclick = async () => { + try { + const link = HaloInvite.link(location.href, $('invite-input').value); + await navigator.clipboard.writeText(link); + toast('Browser invite copied.'); + } catch (error) { $('invite-status').textContent = error.message; } + }; + if (state.invite) { + $('browser-rooms').hidden = true; + if (relay && !state.manualMode) connect(); + } + } + + function onlineOptions() { + const options = {}; + if (settings.turnUrl) { + options.turn = { urls: settings.turnUrl, username: settings.turnUser || '', credential: settings.turnPassword || '' }; + } + // (tests name their own broker: ?signal=ws://...) + const signal = new URLSearchParams(location.search).get('signal'); + if (signal) options.brokers = [signal]; + return options; + } + + function roomLink(code) { + const url = new URL(location.href); + url.search = ''; + url.hash = ''; + url.searchParams.set('room', code); + return url.toString(); + } + + function showOnline(status) { + const inRoom = !!status.room; + $('online-join').hidden = false; + $('online-room').hidden = !inRoom; + $('online-default').disabled = !DEFAULT_ROOM || status.room === DEFAULT_ROOM; + maybeQuickPlay(); + if (!inRoom) return; + $('online-code').textContent = status.room; + $('room-toggle').textContent = 'Room ' + status.room; + const players = status.players === 1 ? '1 other player' : `${status.players} other players`; + $('online-status').textContent = status.brokers ? `Connected: ${players} in the room.` : + 'Looking for the room… (checking the connection)'; + $('online-names').textContent = status.names.length ? status.names.join(', ') : ''; + } + + function roomAction(action) { + state.roomTask = state.roomTask.then(action).catch(error => toast(error.message)); + return state.roomTask; + } + + function setRoomURL(code) { + const url = new URL(location.href); + if (code) url.searchParams.set('room', code); + else url.searchParams.delete('room'); + history.replaceState(history.state, '', url); + } + + function joinRoom(code, { remember = true, updateURL = true } = {}) { + code = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (code.length < 4 || code.length > 16) { + toast('Use a room code with 4–16 letters or digits.'); + return Promise.resolve(); + } + if (code === state.selectedRoom && code === HaloNet.status().room && !state.manualMode && !state.quickFailed) + return state.roomTask; + // Quick play is configured once when the engine starts. Changing only + // WebRTC rooms would leave its old host/client session running on a new LAN. + if (state.started || (state.dataBusy && state.manualMode)) { + state.dataTransition = true; + try { + if (remember) localStorage.setItem('halo-web-room', code); + localStorage.removeItem('halo-web-room-left'); + } catch { /* not kept */ } + const url = new URL(location.href); + url.searchParams.set('room', code); + url.searchParams.delete('menu'); + url.hash = ''; + location.href = url.toString(); + return Promise.resolve(); + } + cancelQuickPlay(); + state.quickFailed = false; + state.selectedRoom = code; + if (updateURL) { + state.manualMode = false; + state.manualRequested = false; + const url = new URL(location.href); + url.searchParams.delete('menu'); + history.replaceState(history.state, '', url); + } + $('online-share').disabled = true; + return roomAction(async () => { + const joined = await HaloNet.join(code, onlineOptions()); + try { + if (remember) localStorage.setItem('halo-web-room', joined); + localStorage.removeItem('halo-web-room-left'); + } catch { /* not kept */ } + if (updateURL) setRoomURL(joined); + $('online-share').disabled = joined !== state.selectedRoom; + updatePlayButton(); + if (state.checksReady && !state.maps) showSteps(await mapsState()); + else maybeQuickPlay(); + }); + } + + function setUpOnline() { + HaloNet.on((type, detail) => { + if (type === 'diagnostic') log(`network ${new Date().toISOString()}: ${JSON.stringify(detail)}`); + if (type === 'status') showOnline(detail); + if (type === 'joined') toast(`${detail.name} joined the room.`); + if (type === 'left') toast(`${detail.name} left the room.`); + }); + let name = ''; + try { name = localStorage.getItem('halo-web-player-name') || ''; } catch { /* none */ } + $('online-name').value = name; + $('online-name').onchange = (event) => { + try { localStorage.setItem('halo-web-player-name', event.target.value.trim().slice(0, 24)); } catch { /* none */ } + }; + $('online-create').onclick = () => joinRoom(HaloNet.newRoomCode()); + $('online-default-room').hidden = !DEFAULT_ROOM; + $('online-default-code').textContent = DEFAULT_ROOM; + $('online-default').onclick = () => { if (DEFAULT_ROOM) return joinRoom(DEFAULT_ROOM); }; + $('online-enter').onclick = () => joinRoom($('online-input').value); + $('online-input').onkeydown = (event) => { if (event.key === 'Enter') joinRoom(event.target.value); }; + $('online-leave').onclick = () => { + if (state.started || !hasFullMaps()) { + state.dataTransition = true; + try { + localStorage.removeItem('halo-web-room'); + localStorage.setItem('halo-web-room-left', '1'); + } catch { /* not kept */ } + const url = new URL(location.href); + url.searchParams.delete('room'); + url.searchParams.set('menu', '1'); + url.hash = ''; + location.href = url.toString(); + return Promise.resolve(); + } + cancelQuickPlay(); + state.selectedRoom = null; + quickStatus('menu', 'Room left. Choose a room or open the main menu.'); + updatePlayButton(); + return roomAction(async () => { + await HaloNet.leave(); + try { + localStorage.removeItem('halo-web-room'); + localStorage.setItem('halo-web-room-left', '1'); + } catch { /* none */ } + setRoomURL(null); + }); + }; + $('online-share').onclick = async () => { + const room = HaloNet.status().room; + if (!room || room !== state.selectedRoom || $('online-share').disabled) return; + const link = roomLink(room); + try { + if (navigator.share) await navigator.share({ title: 'Halo CE room', text: 'Join my Halo game', url: link }); + else { + await navigator.clipboard.writeText(link); + toast('The room link is copied.'); + } + } catch { /* cancelled */ } + }; + $('online-address').textContent = HaloNet.addressText(HaloNet.address); + $('opt-turn-url').value = settings.turnUrl || ''; + $('opt-turn-user').value = settings.turnUser || ''; + $('opt-turn-password').value = settings.turnPassword || ''; + for (const [id, key] of [['opt-turn-url', 'turnUrl'], ['opt-turn-user', 'turnUser'], ['opt-turn-password', 'turnPassword']]) { + $(id).onchange = (event) => { settings[key] = event.target.value.trim(); saveSettings(); }; + } + // An explicit link wins. Otherwise remember a chosen room or a decision + // to leave; new visitors gather in the default public room. + const linked = new URLSearchParams(location.search).get('room'); + let last = null, left = false; + try { + last = localStorage.getItem('halo-web-room'); + left = localStorage.getItem('halo-web-room-left') === '1'; + } catch { /* none */ } + if (!$('invite-input') || !new URLSearchParams(location.hash.slice(1)).has('join')) { + if (linked) joinRoom(linked, { updateURL: false }); + else if (!left && (last || DEFAULT_ROOM)) joinRoom(last || DEFAULT_ROOM, { remember: !!last, updateURL: false }); + } + showOnline(HaloNet.status()); + } + + // ---------- updates + + async function checkForUpdate() { + try { + const current = await (await fetch('version.json')).json(); + state.version = current.version; + $('version').textContent = 'Build ' + current.version; + const latest = await (await fetch('version.json?latest=1', { cache: 'no-store' })).json(); + if (latest.version && latest.version !== current.version && navigator.serviceWorker.controller) { + $('update-notice').hidden = false; + } + } catch { /* offline */ } + } + + function updateFailed() { + $('update-notice').hidden = false; + $('update-message').textContent = 'The update could not be downloaded. Your current game is still available.'; + $('update-button').disabled = false; + $('update-button').textContent = 'Retry update'; + } + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data === 'updated') location.reload(); + if (event.data === 'update-failed') updateFailed(); + }); + } + + // ---------- start + + async function main() { + window.addEventListener('error', (event) => { + log(`error: ${event.message} (${event.filename}:${event.lineno})`); + if (state.started) fatal(event.message || 'An error stopped the game.'); + }); + window.addEventListener('unhandledrejection', (event) => log('unhandled: ' + event.reason)); + window.addEventListener('resize', () => { if (state.shared) updateDisplaySize(); }); + document.addEventListener('visibilitychange', onVisibility); + + $('opt-touch').checked = settings.touch; + $('opt-touch-fps').checked = settings.touchFps === true; + $('opt-look').value = settings.look; + $('opt-vsync').checked = settings.vsync; + $('opt-resolution').value = settings.renderHeight; + $('opt-resolution').onchange = (event) => { + const height = Number(event.target.value); + if (![480, 720, 1080, 1440].includes(height)) return; + settings.renderHeight = height; + saveSettings(); + if (state.shared) updateDisplaySize(); + }; + $('opt-touch').onchange = (event) => { settings.touch = event.target.checked; saveSettings(); }; + $('opt-touch-fps').onchange = (event) => { settings.touchFps = event.target.checked; saveSettings(); }; + $('opt-look').oninput = (event) => { + settings.look = parseFloat(event.target.value); + saveSettings(); + HaloInput.setLookSensitivity(settings.look); + }; + $('opt-vsync').onchange = (event) => { settings.vsync = event.target.checked; saveSettings(); }; + $('opt-gldebug').checked = settings.glDebug; + $('opt-gldebug').onchange = (event) => { settings.glDebug = event.target.checked; saveSettings(); }; + $('iso-file').onchange = onImageChosen; + $('play').onclick = () => { + unlockInteraction(); + if (!state.manualMode && (state.invite || state.selectedRoom)) return maybeQuickPlay(); + return play({ userGesture: true }); + }; + $('main-menu').onclick = openMainMenu; + $('room-toggle').onclick = () => showRoomSettings(true); + $('room-close').onclick = () => showRoomSettings(false); + $('quick-menu').onclick = openMainMenu; + $('fatal-menu').onclick = openMainMenu; + $('update-button').onclick = () => { + if ($('update-button').disabled) return; + $('update-button').disabled = true; + $('update-button').textContent = 'Updating…'; + $('update-message').textContent = 'Downloading the update. The game will restart when it is ready.'; + try { + navigator.serviceWorker.controller.postMessage('update'); + } catch { updateFailed(); } + }; + $('quick-retry').onclick = () => { state.quickFailed = false; state.manualMode = false; return maybeQuickPlay(); }; + for (const type of ['pointerdown', 'keydown', 'touchstart']) { + window.addEventListener(type, (event) => { + // Browser autoplay and pointer lock may require a normal game interaction. + if (state.started && !state.roomSettingsOpen && (state.audio?.state !== 'running' || + (type === 'pointerdown' && event.target === $('screen') && !document.pointerLockElement))) { + unlockInteraction(); + } + }, { passive: true }); + } + $('export-saves').onclick = exportSaves; + $('delete-data').onclick = deleteData; + const showLog = async () => { + $('log-text').textContent = await fullLog(); + $('log-view').hidden = false; + }; + $('show-log').onclick = showLog; + $('quick-log').onclick = showLog; + $('log-close').onclick = () => { $('log-view').hidden = true; }; + $('log-download').onclick = async () => { + const file = await debugFile(); + const contents = [`${navigator.userAgent}\nBuild ${state.version || 'unknown'}\n\n--- page and console ---\n${state.log.join('\n')}\n\n--- debug.txt ---\n`]; + if (file) contents.push(file); + const url = URL.createObjectURL(new Blob(contents, { type: 'text/plain;charset=utf-8' })); + const link = document.createElement('a'); + link.href = url; link.download = 'halo-log-' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt'; + link.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); + }; + $('log-copy').onclick = async () => { + try { await navigator.clipboard.writeText(await fullLog()); toast('Copied.'); } catch { toast('Could not copy.'); } + }; + $('fatal-reload').onclick = () => location.reload(); + $('fatal-log').onclick = async () => { + try { await navigator.clipboard.writeText(await fullLog()); toast('Copied.'); } catch { toast('Could not copy.'); } + }; + + const standalone = navigator.standalone || matchMedia('(display-mode: standalone)').matches || + matchMedia('(display-mode: fullscreen)').matches; + $('install-hint').hidden = standalone || !ios; + $('iphone-tips').hidden = !ios; + $('rotate-iphone-tips').hidden = !ios; + // Android (Chrome, Edge, Samsung Internet): the browser's own install + // prompt when it offers one, otherwise where to find it + const android = /Android/i.test(navigator.userAgent); + $('install-android').hidden = standalone || !android; + window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault(); + state.installPrompt = event; + $('install-android').hidden = standalone; + $('install-button').hidden = false; + $('install-android-menu').hidden = true; + }); + $('install-button').onclick = async () => { + if (!state.installPrompt) return; + state.installPrompt.prompt(); + const choice = await state.installPrompt.userChoice.catch(() => null); + state.installPrompt = null; + if (choice && choice.outcome === 'accepted') $('install-android').hidden = true; + }; + window.addEventListener('appinstalled', () => { $('install-android').hidden = true; }); + + await ensureIsolation(); + // A failed capability or data check must not hide an available repair. + checkForUpdate(); + setUpOnline(); + setUpInvites(); + const ok = await runChecks(); + if (!ok) return; + state.checksReady = true; + showSteps(await mapsState()); + } + + main().catch((error) => { + log('start: ' + (error && error.stack || error)); + addCheck(false, 'The page could not start: ' + error.message); + }); +})(); diff --git a/port/web/site/audio-worklet.js b/port/web/site/audio-worklet.js new file mode 100644 index 0000000000..c6708d310b --- /dev/null +++ b/port/web/site/audio-worklet.js @@ -0,0 +1,78 @@ +/* +AUDIO-WORKLET.JS + +Plays the game's sound: the mixer (port/linux/src/dsound_sdl.c, through +port/web/src/web_sdl.c) writes interleaved stereo float frames at 48 kHz +into a ring in the WebAssembly memory, which is a SharedArrayBuffer, and +this processor reads them at the audio context's rate. +*/ + +'use strict'; + +class HaloAudioProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const { buffer, ring, ringFrames, write, read, underruns, rate } = options.processorOptions; + this.ring = new Float32Array(buffer, ring, ringFrames * 2); + this.words = new Int32Array(buffer); + this.ringMask = ringFrames - 1; + this.writeIndex = write >> 2; + this.readIndex = read >> 2; + this.underrunIndex = underruns >> 2; + // source frames per output frame + this.step = rate / sampleRate; + this.position = 0; + this.port.onmessage = (event) => { + if (event.data && event.data.volume !== undefined) this.volume = event.data.volume; + }; + this.volume = 1; + } + + process(inputs, outputs) { + const output = outputs[0]; + const left = output[0]; + const right = output[1] || output[0]; + const frames = left.length; + const write = Atomics.load(this.words, this.writeIndex); + let read = Atomics.load(this.words, this.readIndex); + const needed = Math.ceil(this.position + frames * this.step); + + if (write - read < needed) { + // not enough yet: silence, and let the ring fill + left.fill(0); + if (right !== left) right.fill(0); + Atomics.add(this.words, this.underrunIndex, 1); + return true; + } + const ring = this.ring; + const mask = this.ringMask; + const volume = this.volume; + if (this.step === 1) { + for (let i = 0; i < frames; i++) { + const slot = ((read + i) & mask) * 2; + left[i] = ring[slot] * volume; + right[i] = ring[slot + 1] * volume; + } + read += frames; + } else { + // linear interpolation when the device does not run at 48 kHz + let position = this.position; + for (let i = 0; i < frames; i++) { + const whole = Math.floor(position); + const fraction = position - whole; + const a = ((read + whole) & mask) * 2; + const b = ((read + whole + 1) & mask) * 2; + left[i] = (ring[a] + (ring[b] - ring[a]) * fraction) * volume; + right[i] = (ring[a + 1] + (ring[b + 1] - ring[a + 1]) * fraction) * volume; + position += this.step; + } + const consumed = Math.floor(position); + read += consumed; + this.position = position - consumed; + } + Atomics.store(this.words, this.readIndex, read); + return true; + } +} + +registerProcessor('halo-audio', HaloAudioProcessor); diff --git a/port/web/site/cache.js b/port/web/site/cache.js new file mode 100644 index 0000000000..2714e0bc08 --- /dev/null +++ b/port/web/site/cache.js @@ -0,0 +1,111 @@ +/* Recognize maps imported from the player's disc image and existing browser caches. */ +'use strict'; + +globalThis.HaloCache = (() => { + const EXPECTED = [ + 'ui', 'a10', 'a30', 'a50', 'b30', 'b40', 'c10', 'c20', 'c40', 'd20', 'd40', + 'beavercreek', 'bloodgulch', 'boardingaction', 'carousel', 'chillout', 'damnation', + 'hangemhigh', 'longest', 'prisoner', 'putput', 'ratrace', 'sidewinder', 'wizard', + ].map(name => name + '.map'); + const DATA_PATH = ['halo', 'data']; + + async function directory(path, create = false) { + let folder = await navigator.storage.getDirectory(); + for (const name of path) folder = await folder.getDirectoryHandle(name, { create }); + return folder; + } + + async function readJSON(path, name) { + try { + return JSON.parse(await (await (await (await directory(path)).getFileHandle(name)).getFile()).text()); + } catch (error) { + if (error.name === 'NotFoundError' || error instanceof SyntaxError) return null; + throw error; + } + } + + async function headerValid(file) { + if (file.size < 2048) return false; + const data = new Uint8Array(await file.slice(0, 2048).arrayBuffer()); + const text = offset => String.fromCharCode(...data.subarray(offset, offset + 4)); + return text(0) === 'daeh' && text(2044) === 'toof'; + } + + async function inspect(path, manifest = null) { + const maps = new Map(); + let folder; + try { folder = await directory(path); } + catch (error) { if (error.name === 'NotFoundError') return maps; throw error; } + for (const name of EXPECTED) { + try { + const file = await (await folder.getFileHandle(name)).getFile(); + if ((!manifest || manifest[name] === file.size) && await headerValid(file)) maps.set(name, file.size); + } catch (error) { + if (error.name !== 'NotFoundError') throw error; + } + } + return maps; + } + + function validateRequired(required) { + if (!Array.isArray(required) || !required.length || new Set(required).size !== required.length || + required.some(name => !EXPECTED.includes(name))) { + throw new TypeError('Required maps must be a nonempty list of unique supported .map names'); + } + return required.slice(); + } + + function mapState(maps, legacy, required) { + if (!required.every(name => maps.has(name))) return null; + return { + files: [...maps.keys()], bytes: [...maps.values()].reduce((sum, size) => sum + size, 0), + requiredBytes: required.reduce((sum, name) => sum + maps.get(name), 0), + dataRoot: legacy ? '/data/halo/data' : '/data', + saveRoot: legacy ? '/data/halo/save' : '/data/save', cached: true, + }; + } + + async function nativeMaps() { + const marker = await readJSON(['maps'], '.complete'); + // Disc extraction commits this marker only after the entire copy finishes. + // Unmarked maps may be leftovers from an interrupted import. + if (!marker || !Array.isArray(marker.files) || !marker.files.length || + new Set(marker.files).size !== marker.files.length || + marker.files.some(name => !EXPECTED.includes(name)) || + !Number.isSafeInteger(marker.bytes) || marker.bytes < 2048) return new Map(); + const folder = await directory(['maps']); + let bytes = 0; + for (const name of marker.files) { + try { bytes += (await (await folder.getFileHandle(name)).getFile()).size; } + catch (error) { if (error.name === 'NotFoundError') return new Map(); throw error; } + } + if (bytes !== marker.bytes) return new Map(); + const maps = await inspect(['maps']); + return new Map([...maps].filter(([name]) => marker.files.includes(name))); + } + + async function mapsState({ required = EXPECTED } = {}) { + required = validateRequired(required); + // Older production imports live below halo/data. Its oldest imports + // predate maps.json and were checked by map header. + const manifest = await readJSON(DATA_PATH, 'maps.json'); + const legacyMaps = await inspect([...DATA_PATH, 'maps'], manifest); + // Before maps.json existed, the production importer copied all 24 maps. + // Keep that complete-cache compatibility without treating interrupted, + // unmarked imports as a committed room subset. + const legacy = manifest || legacyMaps.size === EXPECTED.length ? mapState(legacyMaps, true, required) : null; + if (legacy) return legacy; + return mapState(await nativeMaps(), false, required); + } + + async function withLock(task, signal) { + signal?.throwIfAborted(); + if (!navigator.locks) return task(); + return navigator.locks.request('halo-game', { ifAvailable: true }, lock => { + if (!lock) throw new Error('Halo is open in another tab. Close that tab, then retry.'); + return task(); + }); + } + + return { mapsState, withLock, directory, expected: EXPECTED.slice() }; +})(); diff --git a/port/web/site/gateway.js b/port/web/site/gateway.js new file mode 100644 index 0000000000..fcbfb48d96 --- /dev/null +++ b/port/web/site/gateway.js @@ -0,0 +1,144 @@ +/* Native invite transport. A relay-assigned identity is fixed for one game + * session. Reliable frames queue with bounds instead of disappearing on a + * full shared ring. The relay URL comes from site configuration, never an invite. */ +'use strict'; +const HaloGateway = (() => { + const MAX_QUEUE = 8 * 1024 * 1024; + const MAX_FRAME = 65560; + function idWords(identifier) { + if (!/^[a-f0-9]{12}$/i.test(identifier || '')) throw new Error('Invalid relay identity.'); + const bytes = new Uint8Array(8); + for (let i = 0; i < 6; i++) bytes[i] = parseInt(identifier.slice(i * 2, i * 2 + 2), 16); + const view = new DataView(bytes.buffer); + return [view.getInt32(0, true), view.getInt32(4, true)]; + } + function validAddress(value) { + // Native P2P virtual addresses use 100.64.0.0/10, in little-endian words. + return Number.isInteger(value) && value > 0 && value <= 0xffffffff && + (value & 255) === 100 && ((value >>> 8) & 255) >= 64 && ((value >>> 8) & 255) <= 127; + } + function validFrame(packet) { + if (packet.byteLength < 24 || packet.byteLength > MAX_FRAME || packet.byteLength % 4) return false; + const v = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); + const length = v.getUint32(20, true), kind = v.getUint32(4, true); + return v.getUint32(0, true) === packet.byteLength && kind >= 1 && kind <= 5 && + ((24 + length + 3) & ~3) === packet.byteLength && + ([1, 3].includes(kind) || length === 0); + } + async function connect(urlText, invite, { accessToken = '', onStatus = () => {} } = {}) { + const url = new URL(urlText); + if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('The game relay must use a secure WebSocket.'); + } + if (url.username || url.password || url.search || url.hash) throw new Error('Invalid relay URL.'); + if (!/^[a-f0-9]{64}$/.test(invite)) throw new Error('Invalid desktop invite.'); + const socket = new WebSocket(url); + socket.binaryType = 'arraybuffer'; + const peers = new Map(), queue = []; + let queuedBytes = 0, queueHead = 0, identity = null, shared = null, ended = false, timer; + let resolveReady, rejectReady; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + function report(state, message) { onStatus({ state, message, peers: peers.size }); } + function fail(message) { + if (ended) return; + ended = true; + clearTimeout(timer); + peers.clear(); + queue.length = 0; + queueHead = 0; + queuedBytes = 0; + sync(); + if (shared) Atomics.store(new Int32Array(shared.memory.buffer), + (shared.base + shared.offsets.netLocalAddress) >>> 2, 0); + rejectReady(new Error(message)); + report('error', message); + try { socket.close(1000, 'Session ended'); } catch { /* already closed */ } + } + function sync() { + if (!shared || !identity) return; + const i32 = new Int32Array(shared.memory.buffer); + const at = (key) => (shared.base + shared.offsets[key]) >>> 2; + const id = idWords(identity.identifier); + Atomics.store(i32, at('gatewayIdentifier'), id[0]); + Atomics.store(i32, at('gatewayIdentifier') + 1, id[1]); + Atomics.store(i32, at('netLocalAddress'), identity.address | 0); + let index = 0; + for (const peer of peers.values()) { + const offset = at('gatewayPeers') + index++ * 3, words = idWords(peer.identifier); + Atomics.store(i32, offset + 2, 0); + Atomics.store(i32, offset, words[0]); + Atomics.store(i32, offset + 1, words[1]); + Atomics.store(i32, offset + 2, peer.address | 0); + } + for (; index < shared.offsets.gatewayPeerCount; index++) Atomics.store(i32, at('gatewayPeers') + index * 3 + 2, 0); + Atomics.store(i32, at('gatewayEnabled'), 1); + } + const transport = { + get address() { return identity.address; }, + get hostAddress() { return ended ? 0 : (peers.get(invite.slice(0, 12))?.address || 0) >>> 0; }, + get connected() { return !ended && peers.size > 0; }, + get closed() { return ended; }, + attach(value) { shared = value; sync(); }, + flush(receive) { + if (ended) return; + while (queueHead < queue.length && receive(queue[queueHead])) queuedBytes -= queue[queueHead++].byteLength; + if (queueHead === queue.length) { queue.length = 0; queueHead = 0; } + else if (queueHead > 1024) { queue.splice(0, queueHead); queueHead = 0; } + }, + send(packet) { + if (ended || socket.readyState !== WebSocket.OPEN) return false; + if (socket.bufferedAmount > 1024 * 1024) return false; + try { socket.send(packet); return true; } catch { fail('The relay connection closed. Reload to reconnect.'); return false; } + }, + close() { fail('Disconnected. Reload to join again.'); }, + }; + timer = setTimeout(() => fail('The relay did not respond. Check the relay address and try again.'), 15000); + socket.onopen = () => { + socket.send(JSON.stringify({ type: 'join', invite, ...(accessToken ? { accessToken } : {}) })); + report('connecting', 'Connecting to the desktop host…'); + }; + socket.onmessage = (event) => { + if (ended) return; + try { + if (typeof event.data === 'string') { + if (event.data.length > 4096) throw new Error('Invalid relay message.'); + const message = JSON.parse(event.data); + if (message.type === 'ready') { + if (identity || !validAddress(message.address)) throw new Error('Invalid relay handshake.'); + idWords(message.identifier); + identity = message; + clearTimeout(timer); + resolveReady(transport); + report('waiting', 'Relay connected. Waiting for the invited host…'); + } else if (message.type === 'peer') { + if (!identity || !validAddress(message.address)) throw new Error('Invalid relay peer.'); + idWords(message.identifier); + if (message.connected) peers.set(message.identifier, message); + else peers.delete(message.identifier); + if (peers.size > 32) throw new Error('Too many relay peers.'); + sync(); + report(peers.size ? 'connected' : 'waiting', peers.size ? + 'Host connected. Preparing to join the match…' : + 'Waiting for the host. An old invite stops working when its host closes the game.'); + } else if (message.type === 'error') { + fail(message.message === 'Native host did not connect' ? + 'The invited host did not connect. Ask the host for a fresh invite and try again.' : + 'The relay could not join this invite. The host may be offline, or access may be denied.'); + } + } else { + const packet = new Uint8Array(event.data); + if (!identity || !validFrame(packet)) throw new Error('Invalid relay game packet.'); + if (queuedBytes + packet.byteLength > MAX_QUEUE) throw new Error('The game cannot keep up with the relay. Reload to reconnect.'); + queue.push(packet); + queuedBytes += packet.byteLength; + } + } catch (error) { fail(error.message || 'Invalid relay response.'); } + }; + socket.onclose = () => fail('Relay disconnected. Reload to reconnect.'); + socket.onerror = () => fail('Cannot reach the game relay. Check its address and access key.'); + return ready; + } + return { connect, idWords, validAddress, validFrame }; +})(); +if (typeof module !== 'undefined') module.exports = HaloGateway; diff --git a/port/web/site/icons/icon-180.png b/port/web/site/icons/icon-180.png new file mode 100644 index 0000000000..b5b65c9bb0 Binary files /dev/null and b/port/web/site/icons/icon-180.png differ diff --git a/port/web/site/icons/icon-192.png b/port/web/site/icons/icon-192.png new file mode 100644 index 0000000000..60b71bbe20 Binary files /dev/null and b/port/web/site/icons/icon-192.png differ diff --git a/port/web/site/icons/icon-512.png b/port/web/site/icons/icon-512.png new file mode 100644 index 0000000000..ae24e6a3b3 Binary files /dev/null and b/port/web/site/icons/icon-512.png differ diff --git a/port/web/site/index.html b/port/web/site/index.html new file mode 100644 index 0000000000..ec26bbd725 --- /dev/null +++ b/port/web/site/index.html @@ -0,0 +1,187 @@ + + + + + + + + + + +Halo CE Web + + + + + + + + + +
+
+
+

Halo CE Web

+

The Halo: Combat Evolved decompilation, running in your browser.

+
+ +
+ + + + + + + + + + +
+ +

Browser rooms

+

The room opens a multiplayer match automatically when your game data is ready. + One player hosts. If the host disconnects, gameplay pauses while another connected player + takes over the same match, preserving scores and positions. Your address:

+

Default public room FQLX01 +

+ +
+ + + +
+ +
+ +
+ Settings and data + + +

Choose before starting. The controller layout includes menu buttons; the compact layout focuses on gameplay.

+ + + + +

Online play: a TURN relay, for networks that WebRTC cannot cross directly (some mobile networks).

+ + + +
+ + + +
+

+

+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/port/web/site/input.js b/port/web/site/input.js new file mode 100644 index 0000000000..70a64e6209 --- /dev/null +++ b/port/web/site/input.js @@ -0,0 +1,614 @@ +/* +INPUT.JS + +The player's input, gathered on the page's main thread and handed to the +game through the shared state (port/web/src/web_shared.h): + +- the keyboard and the mouse (with the pointer locked), as SDL events, + which the Linux port's keyboard-and-mouse controls read + (port/linux/README.md, "Controls"); +- game controllers, through the Gamepad API's standard mapping; +- touch controls for phones and tablets without a controller: a stick for + moving, dragging for aiming (as the mouse does) and the controller's + buttons, drawn over the game. +*/ + +'use strict'; + +const HaloInput = (() => { + const EVENT = { KEY: 1, MOUSE_MOTION: 2, MOUSE_BUTTON: 3, MOUSE_WHEEL: 4, FOCUS: 5, GAMEPAD_ADDED: 6 }; + // SDL_GamepadButton + const BUTTON = { + SOUTH: 0, EAST: 1, WEST: 2, NORTH: 3, BACK: 4, GUIDE: 5, START: 6, LEFT_STICK: 7, RIGHT_STICK: 8, + LEFT_SHOULDER: 9, RIGHT_SHOULDER: 10, DPAD_UP: 11, DPAD_DOWN: 12, DPAD_LEFT: 13, DPAD_RIGHT: 14, + }; + const GAMEPAD_TYPE_XBOXONE = 3; + const GAMEPAD_TYPE_PS4 = 5; + const GAMEPAD_TYPE_PS5 = 6; + const TOUCH_SLOT = 4; + const TOUCH_ID = 1000; + + // KeyboardEvent.code -> SDL_Scancode (USB HID usage) + const SCANCODES = {}; + 'abcdefghijklmnopqrstuvwxyz'.split('').forEach((c, i) => { SCANCODES['Key' + c.toUpperCase()] = 4 + i; }); + '1234567890'.split('').forEach((c, i) => { SCANCODES['Digit' + c] = 30 + i; }); + Object.assign(SCANCODES, { + Enter: 40, Escape: 41, Backspace: 42, Tab: 43, Space: 44, Minus: 45, Equal: 46, BracketLeft: 47, + BracketRight: 48, Backslash: 49, Semicolon: 51, Quote: 52, Backquote: 53, Comma: 54, Period: 55, Slash: 56, + CapsLock: 57, PrintScreen: 70, ScrollLock: 71, Pause: 72, Insert: 73, Home: 74, PageUp: 75, Delete: 76, + End: 77, PageDown: 78, ArrowRight: 79, ArrowLeft: 80, ArrowDown: 81, ArrowUp: 82, NumLock: 83, + NumpadDivide: 84, NumpadMultiply: 85, NumpadSubtract: 86, NumpadAdd: 87, NumpadEnter: 88, + Numpad1: 89, Numpad2: 90, Numpad3: 91, Numpad4: 92, Numpad5: 93, Numpad6: 94, Numpad7: 95, Numpad8: 96, + Numpad9: 97, Numpad0: 98, NumpadDecimal: 99, ControlLeft: 224, ShiftLeft: 225, AltLeft: 226, MetaLeft: 227, + ControlRight: 228, ShiftRight: 229, AltRight: 230, MetaRight: 231, + }); + for (let i = 1; i <= 12; i++) SCANCODES['F' + i] = 57 + i; + + let shared = null; // { i32, f32, offsets, base } + let touchEnabled = false; + let touchUsed = false; + let lookSensitivity = 1.4; + let uiActive = false; + const gamepadIds = new Map(); // Gamepad.index -> slot + let nextGamepadId = 1; + const touchPad = { buttons: 0, axes: [0, 0, 0, 0, 0, 0] }; + const rumbleSeen = [0, 0, 0, 0, 0]; + + function word(offset) { return (shared.base + offset) >> 2; } + + function pushEvent(type, a = 0, b = 0, c = 0, d = 0, x = 0, y = 0, extra = 0) { + if (!shared) return; + const { i32, f32, offsets } = shared; + const writeIndex = word(offsets.eventWrite); + const readIndex = word(offsets.eventRead); + const write = Atomics.load(i32, writeIndex); + const read = Atomics.load(i32, readIndex); + if (write - read >= offsets.eventCapacity) return; + const slot = word(offsets.events + (write % offsets.eventCapacity) * offsets.eventSize); + i32[slot] = type; + i32[slot + 1] = a; + i32[slot + 2] = b; + i32[slot + 3] = c; + i32[slot + 4] = d; + f32[slot + 5] = x; + f32[slot + 6] = y; + i32[slot + 7] = extra; + Atomics.store(i32, writeIndex, write + 1); + } + + // ---------- keyboard + + function keycode(event, scancode) { + if (event.key && event.key.length === 1) return event.key.toLowerCase().codePointAt(0); + const named = { Enter: 13, Escape: 27, Backspace: 8, Tab: 9, ' ': 32, Delete: 127 }; + if (named[event.key] !== undefined) return named[event.key]; + return (scancode | (1 << 30)) >>> 0; + } + + function modifiers(event) { + let mod = 0; + if (event.shiftKey) mod |= 0x0001; + if (event.ctrlKey) mod |= 0x0040; + if (event.altKey) mod |= 0x0100; + if (event.metaKey) mod |= 0x0400; + if (event.getModifierState && event.getModifierState('CapsLock')) mod |= 0x2000; + return mod; + } + + function onKey(event, down) { + if (!shared || uiActive || event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return; + const scancode = SCANCODES[event.code]; + if (scancode === undefined) return; + // keep the browser's shortcuts (reload, developer tools) with Ctrl/Cmd + if (!(event.metaKey || (event.ctrlKey && event.code !== 'ControlLeft' && event.code !== 'ControlRight'))) { + event.preventDefault(); + } + pushEvent(EVENT.KEY, scancode, down ? 1 : 0, event.repeat ? 1 : 0, keycode(event, scancode) | 0, 0, 0, + modifiers(event)); + } + + // ---------- mouse (with the pointer locked on the game) + + function mouseButton(button) { + return [1, 2, 3, 4, 5][button] || 0; // left, middle, right, back, forward + } + + function attachMouse(canvas) { + canvas.addEventListener('click', () => { + if (!document.pointerLockElement && canvas.requestPointerLock && matchMedia('(pointer: fine)').matches) { + try { canvas.requestPointerLock(); } catch { /* not allowed here */ } + } + }); + document.addEventListener('mousemove', (event) => { + if (document.pointerLockElement !== canvas) return; + pushEvent(EVENT.MOUSE_MOTION, 0, 0, 0, 0, event.movementX || 0, event.movementY || 0); + }); + document.addEventListener('mousedown', (event) => { + if (document.pointerLockElement !== canvas) return; + pushEvent(EVENT.MOUSE_BUTTON, mouseButton(event.button), 1); + }); + document.addEventListener('mouseup', (event) => { + if (document.pointerLockElement !== canvas) return; + pushEvent(EVENT.MOUSE_BUTTON, mouseButton(event.button), 0); + }); + document.addEventListener('wheel', (event) => { + if (document.pointerLockElement !== canvas) return; + pushEvent(EVENT.MOUSE_WHEEL, 0, 0, 0, 0, 0, event.deltaY < 0 ? 1 : event.deltaY > 0 ? -1 : 0); + }, { passive: true }); + } + + // ---------- controllers + + function gamepadType(gamepad) { + const id = (gamepad.id || '').toLowerCase(); + if (id.includes('dualsense') || id.includes('0ce6')) return GAMEPAD_TYPE_PS5; + if (id.includes('dualshock') || id.includes('054c')) return GAMEPAD_TYPE_PS4; + return GAMEPAD_TYPE_XBOXONE; + } + + function axis(value) { + return Math.max(-32768, Math.min(32767, Math.round(value * 32767))); + } + + function writeGamepad(slot, connected, id, type, buttons, axes) { + const { i32, offsets } = shared; + const base = word(offsets.gamepads + slot * offsets.gamepadSize); + const wasConnected = i32[base] !== 0; + i32[base + 1] = id; + i32[base + 2] = type; + i32[base + 3] = buttons; + for (let i = 0; i < 6; i++) i32[base + 4 + i] = axes[i]; + Atomics.store(i32, base, connected ? 1 : 0); + if (connected && !wasConnected) pushEvent(EVENT.GAMEPAD_ADDED, id); + } + + function rumble(slot, gamepad) { + const { i32, offsets } = shared; + const base = word(offsets.gamepads + slot * offsets.gamepadSize); + const serial = i32[base + 12]; + if (serial === rumbleSeen[slot]) return; + rumbleSeen[slot] = serial; + const actuator = gamepad && gamepad.vibrationActuator; + if (!actuator || !actuator.playEffect) return; + const strong = i32[base + 10] / 65535; + const weak = i32[base + 11] / 65535; + const duration = Math.min(i32[base + 13] || 1000, 5000); + if (strong <= 0 && weak <= 0) { + actuator.reset?.(); + return; + } + actuator.playEffect('dual-rumble', { duration, strongMagnitude: strong, weakMagnitude: weak }).catch(() => {}); + } + + let physicalCount = 0; + + function pollGamepads() { + const pads = navigator.getGamepads ? navigator.getGamepads() : []; + const used = new Set(); + physicalCount = 0; + for (const gamepad of pads) { + if (!gamepad || !gamepad.connected) continue; + let slot = gamepadIds.get(gamepad.index); + if (slot === undefined) { + for (let s = 0; s < TOUCH_SLOT; s++) { + if (![...gamepadIds.values()].includes(s)) { slot = s; break; } + } + if (slot === undefined) continue; + gamepadIds.set(gamepad.index, slot); + slot = gamepadIds.get(gamepad.index); + shared.ids = shared.ids || []; + shared.ids[slot] = nextGamepadId++; + } + used.add(slot); + physicalCount++; + const b = gamepad.buttons; + const pressed = (i) => (b[i] ? (b[i].pressed || b[i].value > 0.5) : false); + let buttons = 0; + const map = [ + [0, BUTTON.SOUTH], [1, BUTTON.EAST], [2, BUTTON.WEST], [3, BUTTON.NORTH], [4, BUTTON.LEFT_SHOULDER], + [5, BUTTON.RIGHT_SHOULDER], [8, BUTTON.BACK], [9, BUTTON.START], [10, BUTTON.LEFT_STICK], + [11, BUTTON.RIGHT_STICK], [12, BUTTON.DPAD_UP], [13, BUTTON.DPAD_DOWN], [14, BUTTON.DPAD_LEFT], + [15, BUTTON.DPAD_RIGHT], [16, BUTTON.GUIDE], + ]; + for (const [index, bit] of map) if (pressed(index)) buttons |= 1 << bit; + const a = gamepad.axes; + const trigger = (i) => (b[i] ? Math.round(Math.max(0, Math.min(1, b[i].value)) * 32767) : 0); + writeGamepad(slot, true, shared.ids[slot], gamepadType(gamepad), buttons, + [axis(a[0] || 0), axis(a[1] || 0), axis(a[2] || 0), axis(a[3] || 0), trigger(6), trigger(7)]); + rumble(slot, gamepad); + } + for (const [index, slot] of [...gamepadIds.entries()]) { + if (!used.has(slot)) { + gamepadIds.delete(index); + writeGamepad(slot, false, 0, 0, 0, [0, 0, 0, 0, 0, 0]); + } + } + // the touch controls are a controller of their own while no other is + const touchActive = touchEnabled && touchUsed && physicalCount === 0; + writeGamepad(TOUCH_SLOT, touchActive, TOUCH_ID, GAMEPAD_TYPE_XBOXONE, touchPad.buttons, touchPad.axes); + document.body.classList.toggle('has-controller', physicalCount > 0); + } + + // ---------- touch controls + + function buildCompactTouchControls(root) { + root.classList.add('fps-controls'); + // Compact actions; movement and aiming are independent multitouch regions. + const layout = [ + ['fire', '◎', { axis: 5 }, 'fps-fire', 'Fire'], + ['a', '↑', { bit: BUTTON.SOUTH }, 'fps-jump', 'Jump'], + ['b', '╱', { bit: BUTTON.EAST }, 'fps-melee', 'Melee'], + ['x', '↻', { bit: BUTTON.WEST }, 'fps-reload', 'Reload / Use'], + ['y', '', { bit: BUTTON.NORTH }, 'fps-weapon-hud', ''], + ['lt', '●', { axis: 4 }, 'fps-grenade', 'Throw'], + ['black', '⇆', { bit: BUTTON.RIGHT_SHOULDER }, 'fps-grenade-type', 'Grenade'], + ['ls', '⌄', { bit: BUTTON.LEFT_STICK }, 'fps-crouch', 'Crouch'], + ['rs', '⊕', { bit: BUTTON.RIGHT_STICK }, 'fps-zoom', 'Zoom'], + ]; + const held = new Map(); // touch identifier -> control + const buttonState = new Map(); // control id -> count of touches + const stick = { id: null, x: 0, y: 0, element: null, knob: null }; + const look = { id: null, x: 0, y: 0 }; + + const stickBase = document.createElement('div'); + stickBase.className = 'touch-stick'; + const knob = document.createElement('div'); + knob.className = 'touch-knob'; + stickBase.appendChild(knob); + root.appendChild(stickBase); + stick.element = stickBase; + stick.knob = knob; + + const controls = {}; + for (const [id, label, action, cls, caption] of layout) { + const element = document.createElement('div'); + element.className = 'touch-button ' + cls; + element.dataset.control = id; + element.setAttribute('aria-label', id === 'y' ? 'Swap weapon' : caption || id); + if (label) { + const glyph = document.createElement('span'); + glyph.className = 'glyph'; + glyph.textContent = label; + element.appendChild(glyph); + } + if (caption) { + const hint = document.createElement('span'); + hint.className = 'caption'; + hint.textContent = caption; + element.appendChild(hint); + } + root.appendChild(element); + controls[id] = { element, action }; + } + + function refreshButtons() { + let buttons = 0; + touchPad.axes[4] = 0; + touchPad.axes[5] = 0; + for (const [id, count] of buttonState) { + if (count <= 0) continue; + const { action } = controls[id]; + if (action.bit !== undefined) buttons |= 1 << action.bit; + if (action.axis !== undefined) touchPad.axes[action.axis] = 32767; + } + touchPad.buttons = buttons; + for (const id in controls) controls[id].element.classList.toggle('pressed', (buttonState.get(id) || 0) > 0); + } + + const STICK_RADIUS = 40; + + function start(event) { + pushEvent(EVENT.FOCUS, 1); + touchUsed = true; + for (const touch of event.changedTouches) { + const target = document.elementFromPoint(touch.clientX, touch.clientY); + const control = target && target.closest && target.closest('.touch-button'); + if (control) { + const id = control.dataset.control; + held.set(touch.identifier, { kind: 'button', id }); + buttonState.set(id, (buttonState.get(id) || 0) + 1); + // aiming while firing: a touch on the fire button also aims + if (id === 'fire' && look.id === null) { + look.id = touch.identifier; + look.x = touch.clientX; + look.y = touch.clientY; + } + continue; + } + if (touch.clientX < window.innerWidth * 0.38 && stick.id === null) { + stick.id = touch.identifier; + stick.x = touch.clientX; + stick.y = touch.clientY; + stick.element.style.left = (touch.clientX - 48) + 'px'; + stick.element.style.top = (touch.clientY - 48) + 'px'; + stick.element.style.bottom = 'auto'; + stick.element.classList.add('active'); + held.set(touch.identifier, { kind: 'stick' }); + } else if (look.id === null) { + look.id = touch.identifier; + look.x = touch.clientX; + look.y = touch.clientY; + held.set(touch.identifier, { kind: 'look' }); + } + } + refreshButtons(); + event.preventDefault(); + } + + function move(event) { + for (const touch of event.changedTouches) { + if (touch.identifier === stick.id) { + let dx = touch.clientX - stick.x; + let dy = touch.clientY - stick.y; + const length = Math.hypot(dx, dy); + if (length > STICK_RADIUS) { + dx *= STICK_RADIUS / length; + dy *= STICK_RADIUS / length; + } + stick.knob.style.transform = `translate(${dx}px, ${dy}px)`; + touchPad.axes[0] = axis(dx / STICK_RADIUS); + touchPad.axes[1] = axis(dy / STICK_RADIUS); + } + if (touch.identifier === look.id) { + const dx = touch.clientX - look.x; + const dy = touch.clientY - look.y; + look.x = touch.clientX; + look.y = touch.clientY; + pushEvent(EVENT.MOUSE_MOTION, 0, 0, 0, 0, dx * lookSensitivity * 2, dy * lookSensitivity * 2); + } + } + event.preventDefault(); + } + + function end(event) { + for (const touch of event.changedTouches) { + const entry = held.get(touch.identifier); + held.delete(touch.identifier); + if (entry && entry.kind === 'button') { + buttonState.set(entry.id, Math.max(0, (buttonState.get(entry.id) || 0) - 1)); + } + if (touch.identifier === stick.id) { + stick.id = null; + stick.knob.style.transform = ''; + stick.element.classList.remove('active'); + // back to its resting place + stick.element.style.left = ''; + stick.element.style.top = ''; + stick.element.style.bottom = ''; + touchPad.axes[0] = 0; + touchPad.axes[1] = 0; + } + if (touch.identifier === look.id) look.id = null; + } + refreshButtons(); + event.preventDefault(); + } + + function releaseTouches() { + held.clear(); buttonState.clear(); + stick.id = null; look.id = null; + touchPad.axes.fill(0); touchPad.buttons = 0; + stick.knob.style.transform = ''; + stick.element.classList.remove('active'); + stick.element.style.left = ''; stick.element.style.top = ''; stick.element.style.bottom = ''; + refreshButtons(); + } + window.addEventListener('blur', releaseTouches); + window.addEventListener('resize', releaseTouches); + window.addEventListener('gamepadconnected', releaseTouches); + document.addEventListener('visibilitychange', () => { if (document.hidden) releaseTouches(); }); + root.addEventListener('contextmenu', event => event.preventDefault()); + root.addEventListener('touchstart', start, { passive: false }); + root.addEventListener('touchmove', move, { passive: false }); + root.addEventListener('touchend', end, { passive: false }); + root.addEventListener('touchcancel', end, { passive: false }); + } + + + function buildTouchControls(root) { + // the original Xbox controller's layout (the Controller S): gem-coloured + // A, B, X and Y in a diamond with the white and black buttons below + // them, the L and R triggers above each side, back and start between + // the thumbs, and the d-pad under the left thumb + const layout = [ + // [id, label, button bit or trigger axis, class, caption] + ['a', 'A', { bit: BUTTON.SOUTH }, 'face face-a', 'Jump'], + ['b', 'B', { bit: BUTTON.EAST }, 'face face-b', 'Melee'], + ['x', 'X', { bit: BUTTON.WEST }, 'face face-x', 'Reload'], + ['y', 'Y', { bit: BUTTON.NORTH }, 'face face-y', 'Swap'], + ['white', '', { bit: BUTTON.LEFT_SHOULDER }, 'duo duo-white', 'Light'], + ['black', '', { bit: BUTTON.RIGHT_SHOULDER }, 'duo duo-black', 'Grenade'], + ['fire', 'R', { axis: 5 }, 'trigger right-side', 'Fire'], + ['lt', 'L', { axis: 4 }, 'trigger left-side', 'Throw'], + ['ls', '', { bit: BUTTON.LEFT_STICK }, 'stick-click left-click', 'Crouch'], + ['rs', '', { bit: BUTTON.RIGHT_STICK }, 'stick-click right-click', 'Zoom'], + ['back', 'BACK', { bit: BUTTON.BACK }, 'system back', ''], + ['start', 'START', { bit: BUTTON.START }, 'system start', ''], + ['up', '', { bit: BUTTON.DPAD_UP }, 'dpad up', ''], + ['down', '', { bit: BUTTON.DPAD_DOWN }, 'dpad down', ''], + ['left', '', { bit: BUTTON.DPAD_LEFT }, 'dpad left', ''], + ['right', '', { bit: BUTTON.DPAD_RIGHT }, 'dpad right', ''], + ]; + const held = new Map(); // touch identifier -> control + const buttonState = new Map(); // control id -> count of touches + const stick = { id: null, x: 0, y: 0, element: null, knob: null }; + const look = { id: null, x: 0, y: 0 }; + + const stickBase = document.createElement('div'); + stickBase.className = 'touch-stick'; + const knob = document.createElement('div'); + knob.className = 'touch-knob'; + stickBase.appendChild(knob); + root.appendChild(stickBase); + stick.element = stickBase; + stick.knob = knob; + + const controls = {}; + const dpad = document.createElement('div'); + dpad.className = 'touch-dpad'; + root.appendChild(dpad); + for (const [id, label, action, cls, caption] of layout) { + const element = document.createElement('div'); + element.className = 'touch-button ' + cls; + element.dataset.control = id; + element.setAttribute('aria-label', caption || id); + if (label) { + const glyph = document.createElement('span'); + glyph.className = 'glyph'; + glyph.textContent = label; + element.appendChild(glyph); + } + if (caption) { + const hint = document.createElement('span'); + hint.className = 'caption'; + hint.textContent = caption; + element.appendChild(hint); + } + (cls.startsWith('dpad') ? dpad : root).appendChild(element); + controls[id] = { element, action }; + } + + function refreshButtons() { + let buttons = 0; + touchPad.axes[4] = 0; + touchPad.axes[5] = 0; + for (const [id, count] of buttonState) { + if (count <= 0) continue; + const { action } = controls[id]; + if (action.bit !== undefined) buttons |= 1 << action.bit; + if (action.axis !== undefined) touchPad.axes[action.axis] = 32767; + } + touchPad.buttons = buttons; + for (const id in controls) controls[id].element.classList.toggle('pressed', (buttonState.get(id) || 0) > 0); + } + + const STICK_RADIUS = 56; + + function start(event) { + touchUsed = true; + for (const touch of event.changedTouches) { + const target = document.elementFromPoint(touch.clientX, touch.clientY); + const control = target && target.closest && target.closest('.touch-button'); + if (control) { + const id = control.dataset.control; + held.set(touch.identifier, { kind: 'button', id }); + buttonState.set(id, (buttonState.get(id) || 0) + 1); + // aiming while firing: a touch on the fire button also aims + if (id === 'fire' && look.id === null) { + look.id = touch.identifier; + look.x = touch.clientX; + look.y = touch.clientY; + } + continue; + } + if (touch.clientX < window.innerWidth * 0.4 && stick.id === null) { + stick.id = touch.identifier; + stick.x = touch.clientX; + stick.y = touch.clientY; + stick.element.style.left = (touch.clientX - 64) + 'px'; + stick.element.style.top = (touch.clientY - 64) + 'px'; + stick.element.style.bottom = 'auto'; + stick.element.classList.add('active'); + held.set(touch.identifier, { kind: 'stick' }); + } else if (look.id === null) { + look.id = touch.identifier; + look.x = touch.clientX; + look.y = touch.clientY; + held.set(touch.identifier, { kind: 'look' }); + } + } + refreshButtons(); + event.preventDefault(); + } + + function move(event) { + for (const touch of event.changedTouches) { + if (touch.identifier === stick.id) { + let dx = touch.clientX - stick.x; + let dy = touch.clientY - stick.y; + const length = Math.hypot(dx, dy); + if (length > STICK_RADIUS) { + dx *= STICK_RADIUS / length; + dy *= STICK_RADIUS / length; + } + stick.knob.style.transform = `translate(${dx}px, ${dy}px)`; + touchPad.axes[0] = axis(dx / STICK_RADIUS); + touchPad.axes[1] = axis(dy / STICK_RADIUS); + } + if (touch.identifier === look.id) { + const dx = touch.clientX - look.x; + const dy = touch.clientY - look.y; + look.x = touch.clientX; + look.y = touch.clientY; + pushEvent(EVENT.MOUSE_MOTION, 0, 0, 0, 0, dx * lookSensitivity * 2, dy * lookSensitivity * 2); + } + } + event.preventDefault(); + } + + function end(event) { + for (const touch of event.changedTouches) { + const entry = held.get(touch.identifier); + held.delete(touch.identifier); + if (entry && entry.kind === 'button') { + buttonState.set(entry.id, Math.max(0, (buttonState.get(entry.id) || 0) - 1)); + } + if (touch.identifier === stick.id) { + stick.id = null; + stick.knob.style.transform = ''; + stick.element.classList.remove('active'); + // back to its resting place + stick.element.style.left = ''; + stick.element.style.top = ''; + stick.element.style.bottom = ''; + touchPad.axes[0] = 0; + touchPad.axes[1] = 0; + } + if (touch.identifier === look.id) look.id = null; + } + refreshButtons(); + event.preventDefault(); + } + + root.addEventListener('touchstart', start, { passive: false }); + root.addEventListener('touchmove', move, { passive: false }); + root.addEventListener('touchend', end, { passive: false }); + root.addEventListener('touchcancel', end, { passive: false }); + } + + // ---------- setup + + function attach({ memory, base, offsets, canvas, touchRoot, touch, touchFps = false }) { + const buffer = memory.buffer; + shared = { i32: new Int32Array(buffer), f32: new Float32Array(buffer), base, offsets, ids: [] }; + window.addEventListener('keydown', (event) => onKey(event, true)); + window.addEventListener('keyup', (event) => onKey(event, false)); + window.addEventListener('blur', () => pushEvent(EVENT.FOCUS, 0)); + window.addEventListener('focus', () => pushEvent(EVENT.FOCUS, 1)); + attachMouse(canvas); + touchEnabled = touch; + if (touch) { + if (touchFps) buildCompactTouchControls(touchRoot); + else buildTouchControls(touchRoot); + touchUsed = true; + } + } + + function setLookSensitivity(value) { + lookSensitivity = value; + } + + function setUIActive(active) { + uiActive = active; + pushEvent(EVENT.FOCUS, active ? 0 : 1); + } + + // the controller's B for a moment (the system's back gesture): the + // keyboard's Backspace, which the controller emulation reads as B + function pressBack() { + pushEvent(EVENT.KEY, 42, 1, 0, 8); + setTimeout(() => pushEvent(EVENT.KEY, 42, 0, 0, 8), 120); + } + + return { attach, pollGamepads, setLookSensitivity, pressBack, setUIActive }; +})(); diff --git a/port/web/site/manifest.webmanifest b/port/web/site/manifest.webmanifest new file mode 100644 index 0000000000..6edb20a403 --- /dev/null +++ b/port/web/site/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Halo CE Web", + "short_name": "Halo CE", + "description": "The Halo: Combat Evolved decompilation in the browser. Needs your own Xbox disc image.", + "start_url": "./", + "scope": "./", + "display": "fullscreen", + "orientation": "landscape", + "background_color": "#0b0d12", + "theme_color": "#0b0d12", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/port/web/site/native-invite.js b/port/web/site/native-invite.js new file mode 100644 index 0000000000..ad3e95c727 --- /dev/null +++ b/port/web/site/native-invite.js @@ -0,0 +1,28 @@ +/* HTTPS invite fragments stay out of HTTP requests and referrer headers. */ +'use strict'; +const HaloInvite = (() => { + function parse(value) { + const text = String(value || '').trim(); + const native = /^(?:halo:\/\/join\/)?([a-f0-9]{64})$/i.exec(text); + if (native) return native[1].toLowerCase(); + try { + const url = new URL(text); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) return null; + const token = new URLSearchParams(url.hash.slice(1)).get('join'); + return /^[a-f0-9]{64}$/i.test(token || '') ? token.toLowerCase() : null; + } catch { return null; } + } + function link(base, value) { + const token = parse(value); + if (!token) throw new Error('Paste a complete halo://join/ invite (64 hexadecimal characters).'); + const url = new URL(base); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('A secure website is required.'); + url.search = ''; + url.hash = 'join=' + token; + return url.toString(); + } + return { parse, link }; +})(); +if (typeof module !== 'undefined') module.exports = HaloInvite; diff --git a/port/web/site/net.js b/port/web/site/net.js new file mode 100644 index 0000000000..bacc26afd6 --- /dev/null +++ b/port/web/site/net.js @@ -0,0 +1,1285 @@ +/* +NET.JS + +Online play: the players of a room form one network, over which the game's +own system link (its discovery broadcasts and its connections) works as on +a LAN. + +- Every copy of the game has an address on that network, 10.x.y.z, kept in + this browser. The game's sockets (port/web/src/web_net.c) put what they + send to other addresses in a ring in the shared memory, and take what + arrives from a second ring. +- The page carries those packets over WebRTC: a data channel to each other + player, reliable and ordered for the game's connections, unreliable for + its datagrams. Broadcasts go to every player. +- Players find each other through public MQTT brokers (over secure + WebSockets), in a topic derived from the room's code. Everything sent there + is encrypted with a key derived from the code, so only those who have it + can read the room's messages or join it. + +A room is a code the players share (or a link with ?room=CODE). NAT that +WebRTC cannot cross without a relay (some mobile networks) needs a TURN +server, which the settings can name. +*/ + +'use strict'; + +// Election is deliberately limited to visible, connected quick-play players. +// A room is not a consensus service: disconnected network partitions can each +// form a game. Once a host is reserved, later participants join that host. +class HaloQuickCoordinator { + static DISCOVERY_MS = 6000; + static SETTLE_MS = 2000; + static RESERVE_MS = 1500; + static TIMEOUT_MS = 35000; + static HOST_GRACE_MS = 10000; + static HEARTBEAT_TIMEOUT_MS = 25000; + constructor(id, address, now) { + this.id = id; + this.address = address; + this.started = now; + this.brokerSince = null; + this.selectedSince = now; + this.selected = null; + this.reservedSince = null; + this.result = null; + this.epoch = 0; + this.recovering = false; + this.hasMatch = false; + this.missingSince = null; + this.presence = { role: 'candidate', hostId: null, phase: 'electing', gamePhase: '', epoch: 0, failover: true, + migration: true, matchId: null, checkpointTick: -1 }; + } + recover(now, epoch = this.epoch + 1) { + if (this.result) this.previousAuthority = { ...this.result, epoch: this.epoch }; + this.freshJoin = !this.result && this.presence.matchId === null; + this.epoch = epoch; + this.recovering = true; + this.started = now; + this.brokerSince = null; + this.selectedSince = now; + this.selected = null; + this.reservedSince = null; + this.result = null; + this.missingSince = null; + this.presence = { ...this.presence, role: 'candidate', hostId: null, phase: 'electing', epoch, failover: true }; + } + select(id, now) { + if (id !== this.selected) { + this.selected = id; this.selectedSince = now; + if (this.presence.role === 'join') this.reservedSince = now; + } + this.presence.hostId = id; + } + hostHealthy(host, now, epoch = this.epoch) { + return host?.open && host.quick?.role === 'host' && host.quick.gamePhase !== 'migration-failed' && + (host.quick.epoch || 0) === epoch && (!host.quick.failover || + ((!Number.isFinite(host.lastPacketAt) || now - host.lastPacketAt < HaloQuickCoordinator.HEARTBEAT_TIMEOUT_MS) && + (host.quick.gamePhase !== 'playing' || host.quick.checkpointTick < 0 || + !Number.isFinite(host.lastProgressAt) || now - host.lastProgressAt < HaloQuickCoordinator.HEARTBEAT_TIMEOUT_MS))); + } + tick(now, peers, brokerReady) { + const sameMatch = peers.filter(peer => peer.open && peer.quick && + (this.presence.matchId === null || peer.quick.matchId === this.presence.matchId)); + const currentHost = peers.find(peer => peer.id === this.result?.hostId); + const healthyAuthority = this.result?.role === 'host' ? this.presence.gamePhase === 'playing' : + this.result?.role === 'join' && this.hostHealthy(currentHost, now); + // A client's suspicion is not a transfer of authority. Keep the live host + // and healthy clients playing; a verified, launched replacement still fences + // the old authority after an actual partition or host failure. + const newest = Math.max(this.epoch, ...sameMatch.filter(peer => !healthyAuthority || + (peer.quick.role === 'host' && peer.quick.phase === 'launched' && peer.quick.gamePhase === 'playing' && + peer.quick.migration && peer.quick.checkpointTick >= 0)).map(peer => peer.quick.epoch || 0)); + if (newest > this.epoch) this.recover(now, newest); + const previousHost = peers.find(peer => peer.id === this.previousAuthority?.hostId); + if (this.recovering && this.presence.role === 'candidate' && this.previousAuthority?.role === 'join' && + this.hostHealthy(previousHost, now, this.previousAuthority.epoch) && + !sameMatch.some(peer => peer.quick.role === 'host' && peer.quick.phase === 'launched' && + peer.quick.gamePhase === 'playing' && (peer.quick.epoch || 0) > this.previousAuthority.epoch)) { + // The suspected host returned before a replacement committed. Its native + // authority never changed: retract the proposal and reattach only this client. + this.epoch = this.previousAuthority.epoch; + this.result = { role: 'join', hostId: previousHost.id, hostAddress: previousHost.address }; + this.recovering = false; + this.missingSince = null; + this.presence = { ...this.presence, role: 'join', hostId: previousHost.id, phase: 'launched', epoch: this.epoch }; + return { result: this.result, reconnect: true }; + } + if (this.result?.role === 'join') { + const host = peers.find(peer => peer.id === this.result.hostId); + const healthy = this.hostHealthy(host, now); + if (healthy) this.missingSince = null; + else { + if (this.missingSince === null) this.missingSince = now; + if (now - this.missingSince < HaloQuickCoordinator.HOST_GRACE_MS) + return { state: 'reconnecting', message: 'The host connection was lost. Waiting briefly for it to return…' }; + this.recover(now); + } + } + if (this.result) return { result: this.result }; + const preservingMatch = this.recovering && this.hasMatch && this.presence.matchId !== null; + if (!preservingMatch && now - this.started >= HaloQuickCoordinator.TIMEOUT_MS) + return { error: 'The room could not agree on a reachable host. Check the connection, then try again.' }; + if (brokerReady && this.brokerSince === null) this.brokerSince = now; + const active = peers.filter(peer => peer.quick && (peer.quick.epoch || 0) === this.epoch && Number.isInteger(peer.address) && + peer.address > 0 && peer.address <= 0xffffffff && + (this.presence.matchId === null || peer.quick.matchId === this.presence.matchId)); + const hosts = active.filter(peer => peer.quick.role === 'host' && peer.quick.gamePhase !== 'migration-failed' && (!this.recovering || + (peer.quick.migration && peer.quick.checkpointTick >= 0 && (this.freshJoin || peer.quick.matchId === this.presence.matchId)))); + if (this.presence.role === 'host') hosts.push({ id: this.id, address: this.address, open: true, quick: this.presence }); + hosts.sort((a, b) => Number(b.quick.phase === 'launched') - Number(a.quick.phase === 'launched') || a.id.localeCompare(b.id)); + if (hosts.length) { + const host = hosts[0]; + if (this.freshJoin) this.presence.matchId = host.quick.matchId; + this.select(host.id, now); + if (!host.open) return { state: 'connecting', message: this.recovering ? + 'Connecting to the replacement host…' : 'Connecting to the room’s host…' }; + if (host.id !== this.id && this.presence.role !== 'join') { + this.presence = { ...this.presence, role: 'join', hostId: host.id, phase: 'reserved', epoch: this.epoch, failover: true }; + this.reservedSince = now; + } + if (now - this.reservedSince < HaloQuickCoordinator.RESERVE_MS) + return { state: 'coordinating', message: 'Confirming the multiplayer host…' }; + this.result = { role: host.id === this.id ? 'host' : 'join', hostId: host.id, hostAddress: host.address }; + return { result: this.result }; + } + if (this.presence.role === 'join') { + if (preservingMatch) { + // A transport can vanish during the replacement reservation. Keep the + // loaded match and its epoch while that host or another survivor returns. + this.recover(now, this.epoch); + return { state: 'recovering', message: 'The replacement host connection was lost. Preserving the match…' }; + } + return { error: 'The selected host left before the game started. Try again.' }; + } + if (active.some(peer => peer.quick.role === 'join')) + return { state: 'connecting', message: 'Waiting for the room’s existing host…' }; + const candidates = active.filter(peer => peer.quick.role === 'candidate'); + const eligible = candidate => candidate.quick.gamePhase !== 'migration-failed' && (!this.recovering || + (candidate.quick.migration && candidate.quick.checkpointTick >= 0 && + candidate.quick.matchId !== null && candidate.quick.matchId === this.presence.matchId)); + // Connected checkpoint owners can discover and acknowledge the same election + // over RTC. An isolated survivor or a new room still needs broker discovery. + const directRecovery = preservingMatch && candidates.some(peer => peer.open && eligible(peer)); + if (!brokerReady && !directRecovery) return { state: 'connecting', message: this.recovering ? + 'Reconnecting room signaling while preserving the match…' : 'Connecting to the multiplayer room…' }; + if (this.brokerSince === null) this.brokerSince = now; + const choices = [...candidates, { id: this.id, quick: this.presence }].filter(eligible); + choices.sort((a, b) => (this.recovering ? b.quick.checkpointTick - a.quick.checkpointTick : 0) || a.id.localeCompare(b.id)); + const selected = choices[0]?.id; + if (!selected) return { state: 'recovering', message: 'Waiting for a player with a complete match checkpoint…' }; + this.select(selected, now); + if (candidates.some(peer => !peer.open)) + return { state: 'connecting', message: 'Connecting the players before choosing a host…' }; + const settled = now - this.brokerSince >= HaloQuickCoordinator.DISCOVERY_MS && + now - this.selectedSince >= HaloQuickCoordinator.SETTLE_MS; + // Each connected candidate must have observed and acknowledged the same + // winner before it can reserve the host role. Idle room members do not vote. + const acknowledged = candidates.filter(eligible).every(peer => peer.quick.hostId === selected); + if (selected === this.id && settled && acknowledged) { + this.presence = { ...this.presence, role: 'host', hostId: this.id, phase: 'reserved', epoch: this.epoch, failover: true }; + this.reservedSince = now; + return { state: 'coordinating', message: 'Preparing to host the room…' }; + } + return { state: 'coordinating', message: 'Choosing a multiplayer host…' }; + } + launched(phase = 'loading') { + if (this.result) { + this.presence.phase = 'launched'; this.presence.gamePhase = phase; + if (phase === 'playing') this.hasMatch = true; + } + } + checkpoint(epoch, tick, matchId) { + if (epoch !== this.epoch || !Number.isInteger(tick) || tick < 0 || tick > 0x7fffffff || + !Number.isInteger(matchId) || matchId < 0 || matchId > 0xffffffff) return false; + if (this.presence.matchId !== null && this.presence.matchId !== matchId) return false; + if (tick < this.presence.checkpointTick) return false; + this.presence.matchId = matchId; + this.presence.checkpointTick = tick; + this.hasMatch = true; + return true; + } +} + +const HaloNet = (() => { + const BROKERS = [ + 'wss://broker.emqx.io:8084/mqtt', + 'wss://broker.hivemq.com:8884/mqtt', + 'wss://test.mosquitto.org:8081/mqtt', + ]; + const STUN = [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478'] }]; + const HELLO_INTERVAL = 3000; + const PEER_TIMEOUT = 20000; + const PACKET_HEADER = 24; + const MIGRATION_MAGIC = 0x484d4731; + const MIGRATION_HEADER = 8; + const MAX_RELIABLE_QUEUE = 8 * 1024 * 1024; + const MAX_CHANNEL_BUFFER = 1024 * 1024; + const PING_CONTROL = 'halo-host-rtt-v1:'; + const QUICK_CONTROL = 'halo-room-quick-v1:'; + const MAX_QUICK_CONTROL = 1024; + const PING_STALE_MS = 10000; + const PING_PEERS = 128; + const KIND = { DATAGRAM: 1, OPEN: 2, DATA: 3, CLOSE: 4, REFUSE: 5 }; + + const state = { + shared: null, // { memory, base, offsets } + room: null, + key: null, + topic: null, + id: randomId(), + address: 0, // network byte order + brokers: [], + peers: new Map(), // id -> peer + byAddress: new Map(),// address -> peer + replacedPeers: new Set(), // obsolete peer ids from reloads at an existing address + pendingCloses: new Map(), // native EOF packets must precede a replacement's OPEN + seen: new Set(), // message ids already handled (several brokers) + listeners: new Set(), + iceServers: STUN, + pumpTimer: null, + helloTimer: null, + transport: null, // native invite gateway, when selected + quick: null, + quickSequence: 0, + roomGeneration: 0, + pings: { signature: '', authority: null, rows: [], updated: 0, sequence: 0, received: 0 }, + pingProbe: 0, + }; + + function randomId() { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + + // ---------- this machine's address + + function addressText(address) { + return [address & 255, (address >>> 8) & 255, (address >>> 16) & 255, address >>> 24].join('.'); + } + + function localAddress() { + let text = null; + try { text = localStorage.getItem('halo-web-net-address'); } catch { /* none */ } + const parts = text ? text.split('.').map(Number) : null; + if (!parts || parts.length !== 4 || parts[0] !== 10) { + const random = crypto.getRandomValues(new Uint8Array(3)); + const b = 1 + (random[0] % 254), c = random[1], d = 1 + (random[2] % 254); + text = `10.${b}.${c}.${d}`; + try { localStorage.setItem('halo-web-net-address', text); } catch { /* not kept */ } + } + const p = text.split('.').map(Number); + return (p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24)) >>> 0; + } + + // ---------- events for the page + + function emit(type, detail) { + for (const listener of state.listeners) listener(type, detail); + } + + function status() { + const connected = [...state.peers.values()].filter((peer) => peer.open).length; + return { + room: state.room, + address: addressText(state.address), + brokers: state.brokers.filter((broker) => broker.ready).length, + players: connected, + names: [...state.peers.values()].filter((peer) => peer.open).map((peer) => peer.name), + hostPings: state.pings.rows.map(([address, ms]) => ({ address: addressText(address), + ms: Date.now() - state.pings.updated < PING_STALE_MS && ms >= 0 ? ms : null })), + }; + } + + // ---------- encryption (AES-GCM, a key from the room's code) + + async function deriveRoom(code) { + const encoder = new TextEncoder(); + const material = await crypto.subtle.importKey('raw', encoder.encode(code), 'PBKDF2', false, ['deriveKey']); + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: encoder.encode('halo-web-room-v1'), iterations: 50000, hash: 'SHA-256' }, + material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode('halo-web-topic-v1:' + code))); + const topic = 'halo-web/v1/' + Array.from(digest.slice(0, 12), (b) => b.toString(16).padStart(2, '0')).join(''); + return { key, topic }; + } + + async function seal(message) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plain = new TextEncoder().encode(JSON.stringify(message)); + const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, state.key, plain)); + const result = new Uint8Array(12 + cipher.length); + result.set(iv); + result.set(cipher, 12); + return result; + } + + async function open(bytes) { + try { + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, 12) }, state.key, bytes.slice(12)); + return JSON.parse(new TextDecoder().decode(plain)); + } catch { + return null; // another room's, or damaged + } + } + + // ---------- MQTT 3.1.1 over WebSockets (QoS 0 only) + + function encodeLength(length) { + const bytes = []; + do { + let byte = length % 128; + length = Math.floor(length / 128); + if (length > 0) byte |= 128; + bytes.push(byte); + } while (length > 0); + return bytes; + } + + function mqttString(text) { + const bytes = new TextEncoder().encode(text); + return [bytes.length >> 8, bytes.length & 255, ...bytes]; + } + + function mqttPacket(type, body) { + return new Uint8Array([type, ...encodeLength(body.length), ...body]); + } + + function connectBroker(url) { + const broker = { url, socket: null, ready: false, buffer: new Uint8Array(0), ping: null, retry: null }; + const start = () => { + broker.buffer = new Uint8Array(0); + let socket; + try { + socket = new WebSocket(url, 'mqtt'); + } catch { + return; + } + broker.socket = socket; + socket.binaryType = 'arraybuffer'; + socket.onopen = () => { + const body = [...mqttString('MQTT'), 4, 0x02, 0, 60, ...mqttString('halo-' + state.id.slice(0, 12) + '-' + + Math.floor(Math.random() * 1e6))]; + socket.send(mqttPacket(0x10, body)); + }; + socket.onmessage = (event) => { + const incoming = new Uint8Array(event.data); + const joined = new Uint8Array(broker.buffer.length + incoming.length); + joined.set(broker.buffer); + joined.set(incoming, broker.buffer.length); + broker.buffer = joined; + parseMqtt(broker); + }; + socket.onclose = () => { + broker.ready = false; + clearInterval(broker.ping); + emit('status', status()); + if (state.room && state.brokers.includes(broker)) broker.retry = setTimeout(start, 5000); + }; + socket.onerror = () => {}; + }; + start(); + return broker; + } + + function parseMqtt(broker) { + for (;;) { + const bytes = broker.buffer; + if (bytes.length < 2) return; + let length = 0, multiplier = 1, index = 1, byte; + do { + if (index >= bytes.length) return; + byte = bytes[index++]; + length += (byte & 127) * multiplier; + multiplier *= 128; + } while (byte & 128); + if (bytes.length < index + length) return; + const type = bytes[0] & 0xF0; + const body = bytes.slice(index, index + length); + broker.buffer = bytes.slice(index + length); + if (type === 0x20) { + if (body.length !== 2 || body[1] !== 0) { broker.socket.close(); return; } + // CONNACK: subscribe to the room + const body2 = [0, 1, ...mqttString(state.topic), 0]; + broker.socket.send(mqttPacket(0x82, body2)); + broker.ready = true; + broker.ping = setInterval(() => { + if (broker.socket.readyState === 1) broker.socket.send(new Uint8Array([0xC0, 0])); + }, 30000); + emit('status', status()); + hello(); + } else if (type === 0x30) { + const topicLength = (body[0] << 8) | body[1]; + let offset = 2 + topicLength; + if (bytes[0] & 0x06) offset += 2; // (a packet id, QoS above 0) + receiveSignal(body.slice(offset)); + } + } + } + + async function publish(message) { + if (!state.room) return; + const generation = state.roomGeneration; + message.from = state.id; + message.mid = randomId(); + const payload = await seal(message); + if (generation !== state.roomGeneration || !state.room) return; + const body = [...mqttString(state.topic), ...payload]; + const packet = mqttPacket(0x30, body); + for (const broker of state.brokers) { + if (broker.ready && broker.socket.readyState === 1) broker.socket.send(packet); + } + } + + async function receiveSignal(bytes) { + const generation = state.roomGeneration; + const message = await open(bytes); + if (generation !== state.roomGeneration || !state.room) return; + if (!message || message.from === state.id || state.seen.has(message.mid)) return; + state.seen.add(message.mid); + if (state.seen.size > 5000) state.seen = new Set([...state.seen].slice(-2000)); + if (message.to && message.to !== state.id) return; + handleSignal(message); + } + + // ---------- peers + + function hello() { + // Once peers are connected, authority announcements must not depend on + // their public broker subscriptions staying available through a handover. + for (const peer of state.peers.values()) sendQuickPresence(peer); + publish({ type: 'hello', address: state.address, name: playerName(), + quick: state.quick ? { ...state.quick.coordinator.presence } : null, quickSequence: state.quickSequence }); + } + + function sendQuickPresence(peer) { + if (!state.quick && state.quickSequence === 0) return; + const channel = peer.unreliable; + if (!peer.open || channel?.readyState !== 'open') return; + const message = QUICK_CONTROL + JSON.stringify({ sequence: state.quickSequence, + quick: state.quick ? { ...state.quick.coordinator.presence } : null }); + if (message.length > MAX_QUICK_CONTROL || channel.bufferedAmount + message.length > MAX_CHANNEL_BUFFER) return; + try { channel.send(message); } catch { /* closing; the next beacon retries */ } + } + + function updateQuickPresence(peer, value, sequence, source) { + if (!Number.isSafeInteger(sequence) || sequence < 0 || sequence <= peer.quickSequence) return; + const presence = value === null ? null : quickPresence(value, peer.id); + if (value !== null && !presence) return; + const previous = peer.quick; + peer.quickSequence = sequence; + peer.quick = presence; + if (presence && (!previous || presence.epoch !== previous.epoch || presence.matchId !== previous.matchId || + presence.checkpointTick > previous.checkpointTick)) peer.lastProgressAt = Date.now(); + if (presence && (!previous || previous.epoch !== presence.epoch || previous.role !== presence.role || + previous.hostId !== presence.hostId || previous.matchId !== presence.matchId)) + emit('diagnostic', { event: 'authority-presence', source, address: addressText(peer.address), + role: presence.role, epoch: presence.epoch, matchId: presence.matchId, sequence }); + } + + function receiveQuickPresence(peer, text) { + if (text.length > MAX_QUICK_CONTROL) return; + try { + const message = JSON.parse(text.slice(QUICK_CONTROL.length)); + if (!message || typeof message !== 'object' || !Object.hasOwn(message, 'quick')) return; + // The current RTC connection supplies the identity. A payload cannot + // announce another player's host claim, and MQTT shares this sequence gate. + updateQuickPresence(peer, message.quick, message.sequence, 'rtc'); + } catch { /* malformed control packet */ } + } + + function playerName() { + try { return localStorage.getItem('halo-web-player-name') || 'Player'; } catch { return 'Player'; } + } + + function peerFor(id, address, name) { + if (state.replacedPeers.has(id)) return null; + let peer = state.peers.get(id); + if (!peer) { + peer = { id, address, name: name || 'Player', pc: null, reliable: null, unreliable: null, open: false, + lastSeen: Date.now(), pendingCandidates: [], quick: null, quickSequence: -1 }; + state.peers.set(id, peer); + } + if (address) { + if (peer.address !== (address >>> 0)) { + peer.pingPending = peer.hostPing = null; + invalidatePeerPing(peer); + } + if (state.byAddress.get(peer.address) === peer) state.byAddress.delete(peer.address); + peer.address = address >>> 0; + const previous = state.byAddress.get(peer.address); + if (previous && previous !== peer) { + // A reload keeps its virtual LAN address but gets a new peer id. Retire + // the old channels before they can mix packets with the new runtime. + state.replacedPeers.add(previous.id); + dropPeer(previous); + } + state.byAddress.set(peer.address, peer); + } + if (name) peer.name = name; + peer.lastSeen = Date.now(); + return peer; + } + + function connectionCurrent(peer, pc) { + return state.peers.get(peer.id) === peer && peer.pc === pc; + } + + function createConnection(peer) { + const previous = peer.pc; + peer.pc = null; + peer.open = false; + peer.pingPending = peer.hostPing = null; + invalidatePeerPing(peer); + retireStreams(peer); + try { previous?.close(); } catch { /* closed */ } + const pc = new RTCPeerConnection({ iceServers: state.iceServers }); + peer.pc = pc; + peer.connectingSince = Date.now(); + peer.reliable = pc.createDataChannel('reliable', { negotiated: true, id: 0, ordered: true }); + peer.unreliable = pc.createDataChannel('unreliable', { negotiated: true, id: 1, ordered: false, maxRetransmits: 0 }); + peer.received = []; + peer.receivedHead = 0; + peer.receivedBytes = 0; + peer.outgoing = []; + peer.outgoingHead = peer.outgoingBytes = 0; + peer.nativeStreams = new Map(); + for (const channel of [peer.reliable, peer.unreliable]) { + channel.binaryType = 'arraybuffer'; + } + peer.reliable.onmessage = (event) => { + if (!connectionCurrent(peer, pc) || !state.shared) return; + peer.lastPacketAt = Date.now(); + const packet = receivePacket(peer, new Uint8Array(event.data)); + if (!packet) return; + if (packet.length < PACKET_HEADER || packet.length > state.shared.offsets.netInBytes || + new DataView(packet.buffer).getUint32(0, true) !== packet.length || + peer.receivedBytes + packet.length > MAX_RELIABLE_QUEUE) { + // Disconnect on overflow instead of silently corrupting the byte stream. + dropPeer(peer, 'invalid packet or receive queue overflow'); + return; + } + peer.received.push(packet); + peer.receivedBytes += packet.length; + pump(); + }; + peer.unreliable.onmessage = (event) => { + if (!connectionCurrent(peer, pc)) return; + peer.lastPacketAt = Date.now(); + // Heartbeats use the room's established channel, independently of MQTT. + // Older launchers ignore these strings and still send normal game packets. + if (typeof event.data === 'string') { + if (event.data === 'halo-room-ping-v1' && peer.unreliable.readyState === 'open') + peer.unreliable.send('halo-room-pong-v1'); + else if (event.data.startsWith(PING_CONTROL)) receivePing(peer, event.data); + else if (event.data.startsWith(QUICK_CONTROL)) receiveQuickPresence(peer, event.data); + return; + } + // Network events also drain output while background timers are throttled. + pump(); + const packet = receivePacket(peer, new Uint8Array(event.data)); + if (packet && !state.pendingCloses.size) incoming(packet); + }; + peer.reliable.bufferedAmountLowThreshold = MAX_CHANNEL_BUFFER / 2; + peer.reliable.onbufferedamountlow = () => { if (connectionCurrent(peer, pc)) pump(); }; + peer.reliable.onopen = () => { + if (!connectionCurrent(peer, pc)) return; + peer.open = true; + peer.lastPacketAt = Date.now(); + sendQuickPresence(peer); + emit('joined', { name: peer.name, address: addressText(peer.address) }); + emit('status', status()); + }; + peer.unreliable.onopen = () => { if (connectionCurrent(peer, pc)) sendQuickPresence(peer); }; + peer.reliable.onclose = () => { if (connectionCurrent(peer, pc)) dropPeer(peer, 'reliable channel closed'); }; + pc.onicecandidate = (event) => { + if (connectionCurrent(peer, pc) && event.candidate) + publish({ type: 'candidate', to: peer.id, candidate: event.candidate.toJSON() }); + }; + pc.onconnectionstatechange = () => { + if (!connectionCurrent(peer, pc)) return; + if (pc.connectionState === 'failed' || pc.connectionState === 'closed') dropPeer(peer, 'RTC ' + pc.connectionState); + }; + return pc; + } + + function dropPeer(peer, reason = 'peer left') { + if (state.peers.get(peer.id) !== peer) return; + state.peers.delete(peer.id); + if (state.byAddress.get(peer.address) === peer) state.byAddress.delete(peer.address); + const pc = peer.pc, wasOpen = peer.open; + peer.pc = null; + peer.open = false; + peer.pingPending = peer.hostPing = null; + invalidatePeerPing(peer); + peer.received = []; + peer.receivedHead = peer.receivedBytes = 0; + peer.outgoing = []; + peer.outgoingHead = peer.outgoingBytes = 0; + emit('diagnostic', { event: 'peer-disconnected', address: addressText(peer.address), reason, + epoch: state.quick?.coordinator.epoch, matchId: state.quick?.coordinator.presence.matchId }); + retireStreams(peer); + try { pc?.close(); } catch { /* closed */ } + if (wasOpen) emit('left', { name: peer.name }); + emit('status', status()); + pump(); + } + + async function handleSignal(message) { + if (state.replacedPeers.has(message.from)) return; + const generation = state.roomGeneration; + if (message.type === 'hello') { + const known = state.peers.get(message.from); + const peer = peerFor(message.from, message.address, message.name); + updateQuickPresence(peer, message.quick, message.quickSequence, 'broker'); + if (!known) hello(); // (so that it knows this machine without waiting) + // the smaller id makes the offer + if (!peer.pc && state.id < message.from) { + const pc = createConnection(peer); + const offer = await pc.createOffer(); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + await pc.setLocalDescription(offer); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + publish({ type: 'offer', to: peer.id, sdp: pc.localDescription.sdp, address: state.address, name: playerName() }); + } + } else if (message.type === 'offer') { + const peer = peerFor(message.from, message.address, message.name); + const pc = createConnection(peer); + await pc.setRemoteDescription({ type: 'offer', sdp: message.sdp }); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + for (const candidate of peer.pendingCandidates.splice(0)) { + await pc.addIceCandidate(candidate).catch(() => {}); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + } + const answer = await pc.createAnswer(); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + await pc.setLocalDescription(answer); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + publish({ type: 'answer', to: peer.id, sdp: pc.localDescription.sdp }); + } else if (message.type === 'answer') { + const peer = state.peers.get(message.from); + if (peer && peer.pc && peer.pc.signalingState === 'have-local-offer') { + const pc = peer.pc; + await pc.setRemoteDescription({ type: 'answer', sdp: message.sdp }); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + for (const candidate of peer.pendingCandidates.splice(0)) { + await pc.addIceCandidate(candidate).catch(() => {}); + if (!connectionCurrent(peer, pc) || generation !== state.roomGeneration) return; + } + } + } else if (message.type === 'candidate') { + const peer = state.peers.get(message.from); + if (!peer) return; + if (peer.pc && peer.pc.remoteDescription) await peer.pc.addIceCandidate(message.candidate).catch(() => {}); + else peer.pendingCandidates.push(message.candidate); + } else if (message.type === 'bye') { + const peer = state.peers.get(message.from); + if (peer) dropPeer(peer); + } + } + + function sweep() { + const now = Date.now(); + for (const peer of [...state.peers.values()]) { + if (peer.open && peer.quick?.failover && now - peer.lastPacketAt > HaloQuickCoordinator.HEARTBEAT_TIMEOUT_MS) { + dropPeer(peer, 'gameplay heartbeat timeout'); + } else if (!peer.open && now - peer.lastSeen > PEER_TIMEOUT) { + dropPeer(peer); + } else if (!peer.open && peer.pc && now - peer.connectingSince > 15000) { + // a connection that never opened (a lost offer): start again at the + // next hello + try { peer.pc.close(); } catch { /* closed */ } + peer.pc = null; + } + if (peer.open && peer.unreliable?.readyState === 'open' && peer.unreliable.bufferedAmount < MAX_CHANNEL_BUFFER) + peer.unreliable.send('halo-room-ping-v1'); + } + sweepPings(now); + } + + // Measure over the gameplay channel, rather than MQTT or ICE candidate + // statistics. Only the selected host probes, then publishes its RTT table. + function pingAuthority() { + const coordinator = state.quick?.coordinator; + const result = coordinator?.result, presence = coordinator?.presence; + if (!result || presence?.gamePhase !== 'playing' || presence.matchId === null || + coordinator.recovering || coordinator.missingSince != null || state.quick.held) return null; + if (!Number.isInteger(presence.matchId) || !Number.isInteger(coordinator.epoch)) return null; + return { hostId: result.hostId, host: result.hostAddress >>> 0, + epoch: coordinator.epoch, match: presence.matchId, local: result.role === 'host' }; + } + + function publishPingMemory() { + if (!state.shared || !Number.isInteger(state.shared.offsets.pingSequence)) return; + const i32 = words(), pings = state.pings, authority = pings.authority; + Atomics.add(i32, field('pingSequence'), 1); + Atomics.store(i32, field('pingHost'), authority?.host || 0); + Atomics.store(i32, field('pingEpoch'), authority?.epoch || 0); + Atomics.store(i32, field('pingUpdated'), pings.updated | 0); + const rows = pings.rows.slice(0, Math.min(PING_PEERS, state.shared.offsets.pingPeerCount)); + Atomics.store(i32, field('pingCount'), rows.length); + rows.forEach(([address, ms], index) => { + Atomics.store(i32, field('pingPeers') + index * 2, address | 0); + Atomics.store(i32, field('pingPeers') + index * 2 + 1, ms); + }); + Atomics.add(i32, field('pingSequence'), 1); + } + + function syncPingAuthority() { + const authority = pingAuthority(); + const signature = authority ? `${state.roomGeneration}:${authority.hostId}:${authority.host}:${authority.epoch}:${authority.match}` : ''; + if (signature !== state.pings.signature) { + state.pings = { signature, authority, rows: [], updated: 0, sequence: 0, received: 0 }; + for (const peer of state.peers.values()) peer.pingPending = peer.hostPing = null; + publishPingMemory(); + } + return authority; + } + + function invalidatePeerPing(peer) { + if (state.pings.authority?.hostId === peer.id) { + state.pings.rows = []; state.pings.updated = 0; + // A reopened connection must not revive an old table sequence. + } else state.pings.rows = state.pings.rows.filter(row => row[0] !== peer.address); + publishPingMemory(); + } + + function sendPing(peer, message) { + if (peer.open && peer.unreliable?.readyState === 'open' && peer.unreliable.bufferedAmount < MAX_CHANNEL_BUFFER) + peer.unreliable.send(PING_CONTROL + JSON.stringify(message)); + } + + function hostPingTable(now, authority, broadcast = true) { + state.pings.rows = [[state.address, 0, state.id], ...[...state.peers.values()] + .filter(peer => peer.open && peer.quick?.epoch === authority.epoch && peer.quick?.matchId === authority.match) + .slice(0, PING_PEERS - 1).map(peer => [peer.address, + peer.hostPing && now - peer.hostPing.at >= 0 && now - peer.hostPing.at < PING_STALE_MS ? peer.hostPing.ms : -1, peer.id])]; + state.pings.updated = now; + publishPingMemory(); + if (!broadcast) return; + const table = { ...authority, local: undefined, type: 'table', sequence: ++state.pings.sequence, rows: state.pings.rows }; + for (const peer of state.peers.values()) sendPing(peer, table); + } + + function sweepPings(now) { + const authority = syncPingAuthority(); + if (!authority?.local) return; + for (const peer of state.peers.values()) { + if (!peer.open || peer.quick?.epoch !== authority.epoch || peer.quick?.matchId !== authority.match) continue; + if (peer.pingPending && now - peer.pingPending.at < PING_STALE_MS && now >= peer.pingPending.at) continue; + peer.pingPending = { sequence: ++state.pingProbe, at: now }; + sendPing(peer, { ...authority, local: undefined, type: 'probe', sequence: peer.pingPending.sequence }); + } + hostPingTable(now, authority); + } + + function receivePing(peer, wire) { + const authority = syncPingAuthority(); + if (!authority || wire.length > 8192 || !peer.open) return; + let message; + try { message = JSON.parse(wire.slice(PING_CONTROL.length)); } catch { return; } + if (!message || message.hostId !== authority.hostId || message.host !== authority.host || + message.epoch !== authority.epoch || message.match !== authority.match || + !Number.isSafeInteger(message.sequence) || message.sequence <= 0) return; + const now = Date.now(); + if (message.type === 'probe' && !authority.local && peer.id === authority.hostId && peer.address === authority.host) { + sendPing(peer, { ...message, type: 'pong' }); + } else if (message.type === 'pong' && authority.local && peer.pingPending?.sequence === message.sequence) { + const ms = now - peer.pingPending.at; + peer.pingPending = null; + if (ms < 0 || ms >= PING_STALE_MS) return; + peer.hostPing = { ms: Math.round(ms), at: now }; + hostPingTable(now, authority, false); + } else if (message.type === 'table' && !authority.local && peer.id === authority.hostId && peer.address === authority.host) { + if (message.sequence <= state.pings.received || !Array.isArray(message.rows) || + message.rows.length < 1 || message.rows.length > PING_PEERS) return; + const addresses = new Set(); + for (const row of message.rows) { + if (!Array.isArray(row) || row.length !== 3 || !/^[a-f0-9]{16}$/.test(row[2] || '') || + !Number.isInteger(row[0]) || row[0] <= 0 || row[0] > 0xffffffff || + !Number.isInteger(row[1]) || row[1] < -1 || row[1] >= PING_STALE_MS || addresses.has(row[0]) || + (row[0] === authority.host && (row[1] !== 0 || row[2] !== authority.hostId))) return; + addresses.add(row[0]); + } + if (!addresses.has(authority.host)) return; + state.pings.received = message.sequence; + // Address reuse after a reload must not attach the departed peer's + // RTT to its replacement, even if an older table arrives late. + state.pings.rows = message.rows.filter(([address, , id]) => + address === state.address ? id === state.id : state.byAddress.get(address)?.id === id); + state.pings.updated = now; + publishPingMemory(); + } + } + + // ---------- the rings (port/web/src/web_shared.h) + + function words() { + return new Int32Array(state.shared.memory.buffer); + } + + function field(name) { + return (state.shared.base + state.shared.offsets[name]) >> 2; + } + + function incoming(packet) { + if (!state.shared || packet.length < PACKET_HEADER) return false; + const i32 = words(); + const bytes = new Uint8Array(state.shared.memory.buffer); + const capacity = state.shared.offsets.netInBytes; + const ring = state.shared.base + state.shared.offsets.netIn; + const write = Atomics.load(i32, field('netInWrite')) >>> 0; + const read = Atomics.load(i32, field('netInRead')) >>> 0; + const size = new DataView(packet.buffer, packet.byteOffset, packet.byteLength).getUint32(0, true); + if (size !== packet.length || capacity - ((write - read) >>> 0) < size) return false; + const start = write & (capacity - 1); + const first = Math.min(capacity - start, size); + bytes.set(packet.subarray(0, first), ring + start); + if (first < size) bytes.set(packet.subarray(first), ring); + Atomics.store(i32, field('netInWrite'), (write + size) | 0); + return true; + } + + function refuse(header) { + // nobody at that address: the connection is refused + const packet = new Uint8Array(PACKET_HEADER); + const view = new DataView(packet.buffer); + view.setUint32(0, PACKET_HEADER, true); + view.setUint32(4, KIND.REFUSE, true); + view.setUint32(8, header.destination, true); + view.setUint32(12, header.source, true); + view.setUint16(16, header.destinationPort, true); + view.setUint16(18, header.sourcePort, true); + view.setUint32(20, 0, true); + incoming(packet); + } + + function isBroadcast(address) { + return address === 0xFFFFFFFF || (address >>> 24) === 255; + } + + function trackStream(peer, packet, outgoing = false) { + const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); + const kind = view.getUint32(4, true); + if (kind !== KIND.OPEN && kind !== KIND.CLOSE && kind !== KIND.REFUSE) return; + // Normalize to the incoming direction: remote address/port, local address/port. + const source = view.getUint32(outgoing ? 12 : 8, true); + const destination = view.getUint32(outgoing ? 8 : 12, true); + const sourcePort = view.getUint16(outgoing ? 18 : 16, true); + const destinationPort = view.getUint16(outgoing ? 16 : 18, true); + const key = `${source}:${sourcePort}:${destination}:${destinationPort}`; + if (kind !== KIND.OPEN) { peer.nativeStreams.delete(key); return; } + const close = new Uint8Array(PACKET_HEADER), header = new DataView(close.buffer); + header.setUint32(0, PACKET_HEADER, true); + header.setUint32(4, KIND.CLOSE, true); + header.setUint32(8, source, true); + header.setUint32(12, destination, true); + header.setUint16(16, sourcePort, true); + header.setUint16(18, destinationPort, true); + peer.nativeStreams.set(key, close); + } + + function retireStreams(peer) { + for (const [key, close] of peer.nativeStreams || []) state.pendingCloses.set(key, close); + peer.nativeStreams?.clear(); + } + + function send(channel, packet) { + if (state.quick) { + const frame = new Uint8Array(packet.length + MIGRATION_HEADER), view = new DataView(frame.buffer); + view.setUint32(0, MIGRATION_MAGIC, true); + view.setUint32(4, state.quick.coordinator.epoch, true); + frame.set(packet, MIGRATION_HEADER); + packet = frame; + } + if (channel && channel.readyState === 'open') { + if (channel.bufferedAmount + packet.length > MAX_CHANNEL_BUFFER) return false; + try { channel.send(packet); return true; } catch { /* closing */ } + } + return false; + } + + function receivePacket(peer, frame) { + const epoch = state.quick?.coordinator.epoch; + let packet = frame; + if (frame.length >= MIGRATION_HEADER && new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(0, true) === MIGRATION_MAGIC) { + const generation = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(4, true); + if (generation !== epoch) return null; + packet = frame.slice(MIGRATION_HEADER); + packet.haloEpoch = generation; + } else if (epoch !== undefined) return null; + if (epoch !== undefined) { + if (packet.length < PACKET_HEADER) return null; + const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); + if (view.getUint32(8, true) !== peer.address) return null; + const destination = view.getUint32(12, true); + if (destination !== state.address && !isBroadcast(destination)) return null; + } + return packet; + } + + function flushPeerOutput(peer) { + while (peer.outgoingHead < peer.outgoing.length) { + const queued = peer.outgoing[peer.outgoingHead]; + if (queued.epoch === state.quick?.coordinator.epoch && !send(peer.reliable, queued.packet)) break; + peer.outgoingHead++; + peer.outgoingBytes -= queued.packet.length; + } + if (peer.outgoingHead === peer.outgoing.length) { + peer.outgoing.length = 0; + peer.outgoingHead = 0; + } else if (peer.outgoingHead > 1024) { + peer.outgoing.splice(0, peer.outgoingHead); + peer.outgoingHead = 0; + } + } + + function queuePeerOutput(peer, packet) { + flushPeerOutput(peer); + if (!peer.outgoingBytes && send(peer.reliable, packet)) return true; + // Keep stream order within this peer, while other players continue to + // receive gameplay and keepalives from the shared native output ring. + if (peer.outgoingBytes + packet.length > MAX_CHANNEL_BUFFER) { + dropPeer(peer, 'send queue overflow'); + return false; + } + peer.outgoing.push({ packet, epoch: state.quick?.coordinator.epoch }); + peer.outgoingBytes += packet.length; + return true; + } + + function pump() { + if (state.pumping) return; + state.pumping = true; + try { pumpOnce(); } finally { state.pumping = false; } + } + + function pumpOnce() { + syncPingAuthority(); + if (!state.shared) return; + if (state.transport) state.transport.flush(incoming); + for (const peer of state.peers.values()) if (peer.open) flushPeerOutput(peer); + for (const [key, close] of state.pendingCloses) { + if (!incoming(close)) break; + state.pendingCloses.delete(key); + } + // A full native ring retains EOF until it fits. Do not admit a new OPEN + // first: the same tuple may already belong to that replacement by then. + for (const peer of state.pendingCloses.size ? [] : state.peers.values()) { + if (!peer.received) continue; + while (peer.receivedHead < peer.received.length) { + const packet = peer.received[peer.receivedHead]; + if (packet.haloEpoch !== state.quick?.coordinator.epoch) { + peer.receivedHead++; + peer.receivedBytes -= packet.length; + continue; + } + if (!incoming(packet)) break; + peer.receivedHead++; + trackStream(peer, packet); + peer.receivedBytes -= packet.length; + } + if (peer.receivedHead === peer.received.length) { + peer.received.length = 0; + peer.receivedHead = 0; + } else if (peer.receivedHead > 1024) { + peer.received.splice(0, peer.receivedHead); + peer.receivedHead = 0; + } + } + const i32 = words(); + const memory = new Uint8Array(state.shared.memory.buffer); + const capacity = state.shared.offsets.netOutBytes; + const ring = state.shared.base + state.shared.offsets.netOut; + let read = Atomics.load(i32, field('netOutRead')) >>> 0; + const write = Atomics.load(i32, field('netOutWrite')) >>> 0; + while (read !== write) { + const start = read & (capacity - 1); + const headerBytes = new Uint8Array(PACKET_HEADER); + for (let index = 0; index < PACKET_HEADER; index++) headerBytes[index] = memory[ring + ((start + index) & (capacity - 1))]; + const view = new DataView(headerBytes.buffer); + const size = view.getUint32(0, true); + if (size < PACKET_HEADER || size > capacity) { + read = write; + break; + } + const packet = new Uint8Array(size); + const first = Math.min(capacity - start, size); + packet.set(memory.subarray(ring + start, ring + start + first)); + if (first < size) packet.set(memory.subarray(ring, ring + size - first), first); + const header = { + kind: view.getUint32(4, true), + source: view.getUint32(8, true), + destination: view.getUint32(12, true), + sourcePort: view.getUint16(16, true), + destinationPort: view.getUint16(18, true), + }; + if (state.transport) { + // Leave reliable bytes in the ring until the socket can accept them. + if (!state.transport.send(packet)) break; + } else if (header.kind === KIND.DATAGRAM) { + if (isBroadcast(header.destination)) { + for (const peer of state.peers.values()) if (peer.open) send(peer.unreliable, packet); + } else { + const peer = state.byAddress.get(header.destination); + if (peer && peer.open) send(peer.unreliable, packet); + } + } else { + const peer = state.byAddress.get(header.destination); + // WebRTC backpressure must not consume bytes the channel did not take. + if (peer && peer.open) { + const tuple = `${header.destination}:${header.destinationPort}:${header.source}:${header.sourcePort}`; + // Native output for a departed endpoint can arrive after the fresh + // RTC channel opens. That generation has not opened this stream yet. + if (header.kind !== KIND.DATA || peer.nativeStreams.has(tuple)) { + if (queuePeerOutput(peer, packet)) trackStream(peer, packet, true); + } + } + else if (header.kind === KIND.OPEN) refuse(header); + } + read = (read + size) >>> 0; + } + Atomics.store(i32, field('netOutRead'), read | 0); + } + + // ---------- the page's interface + + function attach({ memory, base, offsets }) { + state.shared = { memory, base, offsets }; + const i32 = words(); + Atomics.store(i32, field('netLocalAddress'), state.address | 0); + publishPingMemory(); + if (state.transport) state.transport.attach(state.shared); + // (the game's sockets look at the incoming ring every few milliseconds; + // this looks at the outgoing one as often) + state.pumpTimer = setInterval(pump, 4); + } + + async function join(code, options = {}) { + if (state.transport) throw new Error('Reload to switch from a desktop invite to a browser room.'); + code = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (code.length < 4) throw new Error('A room code has at least 4 letters or digits.'); + await leave(); + const { key, topic } = await deriveRoom(code); + state.room = code; + state.key = key; + state.topic = topic; + if (options.turn && options.turn.urls) state.iceServers = [...STUN, options.turn]; + const brokers = options.brokers && options.brokers.length ? options.brokers : BROKERS; + state.brokers = brokers.map(connectBroker); + state.helloTimer = setInterval(() => { hello(); sweep(); }, HELLO_INTERVAL); + emit('status', status()); + return code; + } + + async function leave() { + cancelQuickPlay(); + state.roomGeneration++; + state.replacedPeers.clear(); + if (!state.room) return; + await publish({ type: 'bye' }).catch(() => {}); + clearInterval(state.helloTimer); + for (const peer of [...state.peers.values()]) dropPeer(peer); + for (const broker of state.brokers) { + clearTimeout(broker.retry); + clearInterval(broker.ping); + try { broker.socket && broker.socket.close(); } catch { /* closed */ } + } + state.brokers = []; + state.room = null; + emit('status', status()); + } + + function newRoomCode() { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const bytes = crypto.getRandomValues(new Uint8Array(6)); + return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(''); + } + + function on(listener) { + state.listeners.add(listener); + } + + state.address = localAddress(); + + async function useTransport(transport) { + if (state.shared) throw new Error('Reload before joining another desktop invite.'); + await leave(); + if (state.transport) state.transport.close(); + state.transport = transport; + state.address = transport.address; + } + + function quickPresence(value, id) { + if (!value || !/^[a-f0-9]{16}$/.test(id || '') || !['candidate', 'host', 'join'].includes(value.role)) return null; + if (value.hostId !== null && !/^[a-f0-9]{16}$/.test(value.hostId || '')) return null; + if (!['electing', 'reserved', 'launched'].includes(value.phase)) return null; + if ((value.role === 'candidate') !== (value.phase === 'electing')) return null; + if (value.role === 'host' && value.hostId !== id) return null; + if (value.role === 'join' && (!value.hostId || value.hostId === id)) return null; + return { role: value.role, hostId: value.hostId, phase: value.phase, + epoch: Number.isSafeInteger(value.epoch) && value.epoch >= 0 && value.epoch <= 0x7fffffff ? value.epoch : 0, + failover: value.failover === true, + migration: value.migration === true, + matchId: Number.isInteger(value.matchId) && value.matchId >= 0 && value.matchId <= 0xffffffff ? value.matchId : null, + checkpointTick: Number.isInteger(value.checkpointTick) && value.checkpointTick >= 0 && value.checkpointTick <= 0x7fffffff ? value.checkpointTick : -1, + gamePhase: ['hosting', 'searching', 'joining', 'waiting', 'loading', 'playing', 'migration-failed'].includes(value.gamePhase) ? value.gamePhase : '' }; + } + + function publishQuick() { state.quickSequence++; hello(); } + + function cancelQuickPlay() { + const attempt = state.quick; + if (!attempt) return; + state.quick = null; + syncPingAuthority(); + clearInterval(attempt.timer); + attempt.signal?.removeEventListener('abort', attempt.abort); + if (!attempt.resolved) attempt.reject(new DOMException('Quick play cancelled.', 'AbortError')); + publishQuick(); + } + + async function quickPlay({ signal, onStatus = () => {}, onFailover = () => {}, onReconnect = () => {} } = {}) { + if (signal?.aborted) throw new DOMException('Quick play cancelled.', 'AbortError'); + if (state.transport) { + if (!state.transport.connected || !state.transport.hostAddress) throw new Error('The invited host is not connected.'); + return { role: 'join', room: null, hostId: null, hostAddress: state.transport.hostAddress }; + } + if (!state.room) throw new Error('Join a browser room before starting multiplayer.'); + if (state.quick) return state.quick.promise; + const coordinator = new HaloQuickCoordinator(state.id, state.address, Date.now()); + const attempt = { coordinator, room: state.room, signal, timer: null, resolved: false, + previous: JSON.stringify(coordinator.presence), lastStatus: '', abort: cancelQuickPlay, lastResult: null, + held: false, migrationPending: false, wireEpoch: 0, onReconnect }; + attempt.promise = new Promise((resolve, reject) => { attempt.resolve = resolve; attempt.reject = reject; }); + state.quick = attempt; + signal?.addEventListener('abort', attempt.abort, { once: true }); + const tick = () => { + if (state.quick !== attempt) return; + const result = coordinator.tick(Date.now(), [...state.peers.values()], state.brokers.some(broker => broker.ready)); + if (coordinator.epoch !== attempt.wireEpoch) { + attempt.wireEpoch = coordinator.epoch; + for (const peer of state.peers.values()) peer.nativeStreams?.clear(); + if (state.shared) { + const i32 = words(); + Atomics.store(i32, field('netOutRead'), Atomics.load(i32, field('netOutWrite'))); + } + if (attempt.resolved) { + attempt.held = true; + onStatus({ state: 'recovering', message: 'Choosing a replacement host and preserving the match…', hold: true }); + } + } + const presence = JSON.stringify(coordinator.presence); + if (presence !== attempt.previous) { attempt.previous = presence; publishQuick(); } + if (result.error) { + if (attempt.resolved) onStatus({ state: 'error', message: result.error }); + attempt.resolved = true; // report this failure, rather than an AbortError + attempt.reject(new Error(result.error)); + cancelQuickPlay(); + } else if (result.result) { + const selection = { ...result.result, room: attempt.room, epoch: coordinator.epoch }; + const signature = JSON.stringify(result.result) + ':' + coordinator.epoch; + if (result.reconnect) { + attempt.migrationPending = false; + reconnectQuickClient(attempt); + } + if (signature !== attempt.lastResult) { + attempt.lastResult = signature; + if (!attempt.resolved) { attempt.resolved = true; attempt.resolve(selection); } + else { attempt.held = true; attempt.migrationPending = true; onFailover(selection); } + } else if (attempt.held && !attempt.migrationPending && !coordinator.recovering && coordinator.missingSince === null && + coordinator.presence.gamePhase === 'playing') { + attempt.held = false; + onStatus({ state: 'playing', message: 'Multiplayer is ready.', hold: false }); + } + coordinator.recovering = false; + } else if (result.message !== attempt.lastStatus) { + attempt.lastStatus = result.message; + const hold = attempt.resolved && (coordinator.recovering || result.state === 'reconnecting'); + if (hold) attempt.held = true; + if (result.state === 'reconnecting' && coordinator.hasMatch) reconnectQuickClient(attempt); + onStatus({ state: coordinator.recovering ? 'recovering' : result.state, + message: result.message, + ...(hold ? { hold: true } : {}) }); + } + }; + publishQuick(); + attempt.timer = setInterval(tick, 100); + tick(); + return attempt.promise; + } + + function quickPlayPhase(phase) { + if (phase === 'menu' || phase === 'error') { + const coordinator = state.quick?.coordinator; + if (coordinator && (coordinator.recovering || coordinator.missingSince !== null)) + return true; + cancelQuickPlay(); return false; + } + if (!state.quick || !state.quick.resolved) return; + if (phase === 'migration-failed') { + const attempt = state.quick, coordinator = attempt.coordinator; + attempt.held = true; + attempt.migrationPending = true; + coordinator.presence.gamePhase = phase; + // A checkpoint from the preceding authority cannot make a failed + // reattachment into a healthy host. A later verified transfer and native + // playing receipt can restore eligibility without resetting the match. + coordinator.presence.checkpointTick = -1; + if (coordinator.presence.role === 'host') coordinator.recover(Date.now(), coordinator.epoch); + publishQuick(); + return false; + } + if (!['hosting', 'searching', 'joining', 'waiting', 'loading', 'playing'].includes(phase)) return; + if (phase === 'playing') state.quick.migrationPending = false; + state.quick.coordinator.launched(phase); + publishQuick(); + } + + function quickPlayCheckpoint({ epoch, tick, matchId }) { + if (state.quick?.coordinator.checkpoint(epoch, tick, matchId)) publishQuick(); + } + + function reconnectQuickClient(attempt) { + const coordinator = attempt.coordinator; + if (attempt.migrationPending || coordinator.result?.role !== 'join') return; + attempt.held = true; + attempt.migrationPending = true; + attempt.onReconnect({ ...coordinator.result, room: attempt.room, epoch: coordinator.epoch }); + } + + function quickPlayLost() { + const attempt = state.quick; + if (!attempt?.resolved) return false; + const coordinator = attempt.coordinator; + // Native EOF can arrive after signalling has begun the election, or after + // its replacement selection has already been sent to the engine. Consume + // those old-session receipts without cancelling the preserved match or + // advancing its authority a second time. + // Native playing completes the handshake before the page's next tick + // clears held. A new failure in that interval needs another repair; the + // presentation hold alone does not mean native recovery is still pending. + if (coordinator.hasMatch && (coordinator.recovering || attempt.migrationPending)) return true; + if (coordinator.result?.role === 'host') { + if (!coordinator.hasMatch) return false; + // A broken host's local native connection cannot serve this authority. + // Keep its world, withdraw its candidacy, and follow a healthy survivor + // rather than cancelling quick play or immediately choosing it again. + coordinator.presence.gamePhase = 'migration-failed'; + coordinator.presence.checkpointTick = -1; + } else if (coordinator.result?.role !== 'join') return false; + else { + reconnectQuickClient(attempt); + return true; + } + attempt.held = true; + coordinator.recover(Date.now()); + publishQuick(); + return true; + } + + return { attach, join, leave, newRoomCode, on, status, addressText, useTransport, + quickPlay, cancelQuickPlay, quickPlayLost, quickPlayPhase, quickPlayCheckpoint, quickPlayStarted: () => quickPlayPhase('loading'), + get address() { return state.address; } }; +})(); +if (typeof module !== 'undefined') module.exports = { HaloQuickCoordinator }; diff --git a/port/web/site/site-config.js b/port/web/site/site-config.js new file mode 100644 index 0000000000..dc2de263de --- /dev/null +++ b/port/web/site/site-config.js @@ -0,0 +1,3 @@ +// A deployment sets relayUrl to its WSS /join endpoint. Invites cannot override it. +// Keep access keys out of this public file. +window.HALO_BROWSER_CONFIG = Object.freeze({ relayUrl: '', defaultRoom: 'FQLX01' }); diff --git a/port/web/site/style.css b/port/web/site/style.css new file mode 100644 index 0000000000..22fa7e6a74 --- /dev/null +++ b/port/web/site/style.css @@ -0,0 +1,491 @@ +:root { + --bg: #0b0d12; + --panel: #151922; + --panel-edge: #262c3a; + --text: #e8ebf2; + --muted: #9aa3b5; + --accent: #7fd1ff; + --accent-strong: #3fb4f2; + --danger: #ff6b6b; + --ok: #7be38c; + --warn: #ffd166; + color-scheme: dark; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -webkit-text-size-adjust: 100%; + overscroll-behavior: none; +} + +body.playing { + overflow: hidden; + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +#screen { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + height: 100dvh; + display: none; + background: #000; + object-fit: contain; + image-rendering: auto; +} + +body.playing #screen { display: block; } +body.playing #launcher { display: none; } +body.playing.room-settings { touch-action: auto; user-select: text; -webkit-user-select: text; } +body.playing.room-settings #launcher { + display: flex; + position: fixed; + inset: 0; + z-index: 20; + overflow: auto; + align-items: flex-start; + background: rgba(11, 13, 18, 0.96); +} +.room-toggle { + position: fixed; + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + z-index: 15; +} + +.quick-panel { + position: fixed; + z-index: 15; + left: 50%; + transform: translateX(-50%); + width: min(560px, calc(100vw - 32px)); + padding: 12px 16px; + border: 1px solid var(--panel-edge); + border-radius: 12px; + background: rgba(21, 25, 34, 0.96); +} +.quick-panel { top: max(12px, env(safe-area-inset-top)); } +.quick-panel p { margin: 0 0 10px; } +.quick-panel[data-state=error] { border-color: var(--danger); } + +#launcher { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) + max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); +} + +.panel { + width: 100%; + max-width: 560px; + background: var(--panel); + border: 1px solid var(--panel-edge); + border-radius: 14px; + padding: 20px; +} + +.panel.wide { max-width: 900px; } + +h1 { margin: 0; font-size: 28px; letter-spacing: 0.02em; } +h1 span { color: var(--accent); font-weight: 500; } +h2 { font-size: 17px; margin: 18px 0 6px; } +.subtitle { color: var(--muted); margin: 4px 0 12px; } +.small { color: var(--muted); font-size: 13px; } +.legal { color: var(--muted); font-size: 12px; margin: 18px 0 0; } +code { background: #0e1117; padding: 1px 5px; border-radius: 4px; } + +.checks { display: grid; gap: 4px; margin: 8px 0; font-size: 14px; } +.check::before { content: "✓ "; color: var(--ok); } +.check.bad::before { content: "✕ "; color: var(--danger); } +.check.warn::before { content: "! "; color: var(--warn); } + +.hint { + background: #10202b; + border: 1px solid #1f4058; + border-radius: 10px; + padding: 10px 12px; + margin: 10px 0; + font-size: 14px; +} + +.share-glyph { display: inline-block; font-weight: 700; color: var(--accent); } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 18px; + border-radius: 10px; + border: 1px solid var(--panel-edge); + background: #1d2330; + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.button.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: #04121c; } +.button.danger { color: var(--danger); } +.button:disabled { opacity: 0.5; } +#play { width: 100%; font-size: 18px; min-height: 52px; margin-top: 12px; } + +.progress { margin-top: 12px; } +.bar { height: 8px; background: #0e1117; border-radius: 4px; overflow: hidden; } +#progress-fill { height: 100%; width: 0; background: var(--accent-strong); transition: width 0.2s; } + + +.online { margin-top: 18px; border-top: 1px solid var(--panel-edge); padding-top: 4px; } +.online input[type=text] { + flex: 1; + min-width: 120px; + min-height: 40px; + padding: 0 10px; + border-radius: 8px; + border: 1px solid var(--panel-edge); + background: #0e1117; + color: var(--text); + font: inherit; +} +.room-line { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0 4px; } +.room-line strong { font-size: 20px; letter-spacing: 0.12em; color: var(--accent); } +.small-button { min-height: 34px; padding: 0 12px; font-size: 13px; } +.settings input[type=text], .settings input[type=password], .settings select { + flex: 1; + min-width: 120px; + min-height: 36px; + padding: 0 8px; + border-radius: 8px; + border: 1px solid var(--panel-edge); + background: #0e1117; + color: var(--text); + font: inherit; +} +.settings { margin-top: 16px; border-top: 1px solid var(--panel-edge); padding-top: 10px; } +.settings summary { cursor: pointer; color: var(--muted); min-height: 32px; } +.row { display: flex; gap: 10px; align-items: center; margin: 8px 0; flex-wrap: wrap; } +.row input[type=range] { flex: 1; min-width: 120px; } +.row.buttons .button { flex: 1 1 auto; } + +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(5, 6, 9, 0.92); + z-index: 20; + padding: 16px; + text-align: center; +} + +.overlay[hidden] { display: none; } +.overlay pre { + text-align: left; + white-space: pre-wrap; + word-break: break-word; + max-height: 60vh; + overflow: auto; + font-size: 12px; + background: #0e1117; + padding: 10px; + border-radius: 8px; +} + +.toast { + position: fixed; + left: 50%; + top: max(12px, env(safe-area-inset-top)); + transform: translateX(-50%); + background: rgba(21, 25, 34, 0.95); + border: 1px solid var(--panel-edge); + padding: 8px 14px; + border-radius: 10px; + z-index: 30; + font-size: 14px; + max-width: calc(100vw - 32px); +} + +.update-notice { + position: fixed; + left: 50%; + bottom: max(12px, env(safe-area-inset-bottom)); + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + width: max-content; + max-width: calc(100vw - 32px); + padding: 8px 14px; + border: 1px solid var(--accent); + border-radius: 10px; + background: rgba(21, 25, 34, 0.98); + z-index: 31; + font-size: 14px; +} +.update-notice .button { flex-shrink: 0; } + +/* ---------- touch controls + +Laid out like the original Xbox controller (the Controller S): A, B, X and Y +as green, red, blue and yellow translucent gems in a diamond, the white and +black buttons below them, the L and R triggers above each side, back and +start between the thumbs, a round d-pad under the left thumb, and the left +stick where the left thumb lands. */ + +.touch-layer { + position: fixed; + inset: 0; + z-index: 10; + touch-action: none; + --edge-left: env(safe-area-inset-left); + --edge-right: env(safe-area-inset-right); + --edge-bottom: env(safe-area-inset-bottom); + --face: 56px; + --gap: 5px; + /* the controller's charcoal body */ + --body: rgba(28, 30, 28, 0.5); + --rim: rgba(160, 170, 160, 0.45); +} + +body.has-controller .touch-layer { display: none; } + +.touch-button { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + color: rgba(235, 240, 235, 0.95); + background: var(--body); + border: 1.5px solid var(--rim); + -webkit-user-select: none; + user-select: none; + font-family: "Arial Black", "Helvetica Neue", Arial, sans-serif; + transition: transform 0.06s, filter 0.06s; +} + +.touch-button.pressed { transform: scale(0.92); filter: brightness(1.45); } + +.touch-button .glyph { font-weight: 900; line-height: 1; } +.touch-button .caption { + position: absolute; + top: 100%; + margin-top: 3px; + font: 700 9px/1 -apple-system, BlinkMacSystemFont, Roboto, sans-serif; + letter-spacing: 0.05em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); +} + +/* A, B, X and Y: domed translucent gems, their letters embossed */ +.touch-button.face { + width: var(--face); + height: var(--face); + border-radius: 50%; + border: 1.5px solid rgba(255, 255, 255, 0.35); + box-shadow: inset 0 -6px 10px rgba(0, 0, 0, 0.35), inset 0 5px 8px rgba(255, 255, 255, 0.35), + 0 3px 6px rgba(0, 0, 0, 0.45); + --diamond-right: calc(46px + var(--edge-right)); + --diamond-bottom: calc(96px + var(--edge-bottom)); +} + +.touch-button.face .glyph { + font-size: 22px; + color: rgba(255, 255, 255, 0.92); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.35), 0 1px 1px rgba(255, 255, 255, 0.4); +} + +.face-a { background: radial-gradient(circle at 38% 30%, rgba(160, 235, 120, 0.85), rgba(40, 150, 40, 0.72) 60%, rgba(15, 80, 20, 0.8)); } +.face-b { background: radial-gradient(circle at 38% 30%, rgba(255, 150, 150, 0.85), rgba(210, 30, 35, 0.72) 60%, rgba(110, 10, 15, 0.8)); } +.face-x { background: radial-gradient(circle at 38% 30%, rgba(150, 200, 255, 0.85), rgba(30, 90, 210, 0.72) 60%, rgba(10, 40, 120, 0.8)); } +.face-y { background: radial-gradient(circle at 38% 30%, rgba(255, 240, 150, 0.9), rgba(235, 190, 20, 0.75) 60%, rgba(140, 100, 0, 0.8)); } +.face-y .glyph { color: rgba(70, 50, 0, 0.85); text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); } + +.face-a { right: calc(var(--diamond-right) + var(--face) + var(--gap)); bottom: var(--diamond-bottom); } +.face-b { right: var(--diamond-right); bottom: calc(var(--diamond-bottom) + var(--face) + var(--gap)); } +.face-x { right: calc(var(--diamond-right) + 2 * (var(--face) + var(--gap))); bottom: calc(var(--diamond-bottom) + var(--face) + var(--gap)); } +.face-y { right: calc(var(--diamond-right) + var(--face) + var(--gap)); bottom: calc(var(--diamond-bottom) + 2 * (var(--face) + var(--gap))); } + +/* white and black: smaller gems below the diamond, towards the edge */ +.touch-button.duo { + width: 42px; + height: 42px; + border-radius: 50%; + bottom: calc(22px + var(--edge-bottom)); + box-shadow: inset 0 -5px 8px rgba(0, 0, 0, 0.35), inset 0 4px 6px rgba(255, 255, 255, 0.3), 0 3px 6px rgba(0, 0, 0, 0.45); +} + +.duo-white { + right: calc(76px + var(--edge-right)); + background: radial-gradient(circle at 38% 30%, rgba(255, 255, 255, 0.95), rgba(215, 220, 215, 0.85) 60%, rgba(150, 155, 150, 0.85)); + border-color: rgba(255, 255, 255, 0.7); +} + +.duo-black { + right: calc(24px + var(--edge-right)); + background: radial-gradient(circle at 38% 30%, rgba(110, 110, 110, 0.95), rgba(30, 30, 30, 0.9) 60%, rgba(5, 5, 5, 0.9)); + border-color: rgba(150, 150, 150, 0.6); +} + +/* the triggers: the controller's curved L and R above each side */ +.touch-button.trigger { + width: 96px; + height: 52px; + top: calc(56px + env(safe-area-inset-top)); + background: linear-gradient(180deg, rgba(90, 95, 90, 0.6), rgba(30, 32, 30, 0.6)); +} + +.touch-button.trigger .glyph { font-size: 22px; } +.touch-button.trigger.left-side { left: calc(26px + var(--edge-left)); border-radius: 30px 14px 10px 22px; } +.touch-button.trigger.right-side { right: calc(26px + var(--edge-right)); border-radius: 14px 30px 22px 10px; } + +/* the stick clicks: pressing a thumbstick, which a touch screen cannot do */ +.touch-button.stick-click { + width: 40px; + height: 40px; + border-radius: 50%; + background: radial-gradient(circle, rgba(60, 64, 60, 0.6) 35%, rgba(25, 27, 25, 0.6) 36%); +} + +.touch-button.left-click { left: calc(200px + var(--edge-left)); bottom: calc(var(--edge-bottom) + 176px); } +.touch-button.right-click { right: calc(236px + var(--edge-right)); bottom: calc(var(--edge-bottom) + 176px); } + +/* back and start: the small black buttons between the thumbs */ +.touch-button.system { + width: 54px; + height: 26px; + border-radius: 13px; + bottom: calc(24px + var(--edge-bottom)); + background: rgba(15, 15, 15, 0.65); +} + +.touch-button.system .glyph { font: 800 9px/1 -apple-system, BlinkMacSystemFont, Roboto, sans-serif; letter-spacing: 0.08em; } +.touch-button.back { left: calc(50% - 62px); } +.touch-button.start { left: calc(50% + 8px); } + +/* the d-pad: a round plate with a cross, each arm a button */ +.touch-dpad { + position: absolute; + left: calc(190px + var(--edge-left)); + bottom: calc(var(--edge-bottom) + 14px); + width: 124px; + height: 124px; + border-radius: 50%; + background: radial-gradient(circle at 40% 35%, rgba(80, 84, 80, 0.5), rgba(25, 27, 25, 0.55)); + border: 1.5px solid var(--rim); +} + +.touch-dpad .touch-button { + width: 38px; + height: 40px; + border-radius: 4px; + background: rgba(20, 21, 20, 0.7); + border-color: rgba(120, 125, 120, 0.4); +} + +.touch-dpad .up { left: 42px; top: 8px; } +.touch-dpad .down { left: 42px; bottom: 8px; } +.touch-dpad .left { left: 8px; top: 41px; width: 40px; height: 38px; } +.touch-dpad .right { right: 8px; top: 41px; width: 40px; height: 38px; } + +.touch-dpad .touch-button::after { + content: ""; + width: 0; + height: 0; + border: 6px solid transparent; +} + +.touch-dpad .up::after { border-bottom-color: rgba(220, 225, 220, 0.8); transform: translateY(-3px); } +.touch-dpad .down::after { border-top-color: rgba(220, 225, 220, 0.8); transform: translateY(3px); } +.touch-dpad .left::after { border-right-color: rgba(220, 225, 220, 0.8); transform: translateX(-3px); } +.touch-dpad .right::after { border-left-color: rgba(220, 225, 220, 0.8); transform: translateX(3px); } + +/* the left stick: a concave black thumbstick where the thumb lands */ +.touch-stick { + position: absolute; + width: 128px; + height: 128px; + border-radius: 50%; + border: 1.5px solid var(--rim); + background: radial-gradient(circle, rgba(28, 30, 28, 0.2) 0%, rgba(28, 30, 28, 0.5) 100%); + left: calc(34px + var(--edge-left)); + bottom: calc(46px + var(--edge-bottom)); + pointer-events: none; + opacity: 0.65; + transition: opacity 0.1s; +} + +.touch-stick.active { opacity: 1; } + +.touch-knob { + position: absolute; + left: 32px; + top: 32px; + width: 64px; + height: 64px; + border-radius: 50%; + background: radial-gradient(circle at 50% 50%, rgba(18, 18, 18, 0.95) 38%, rgba(55, 58, 55, 0.95) 40%, + rgba(30, 32, 30, 0.95) 70%); + border: 1.5px solid rgba(140, 145, 140, 0.6); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.55); +} + +/* smaller phones in landscape */ +@media (max-height: 380px) { + .touch-layer { --face: 50px; } + .touch-button.face { --diamond-bottom: calc(86px + var(--edge-bottom)); } + .touch-button.trigger { top: calc(46px + env(safe-area-inset-top)); height: 46px; width: 86px; } + .touch-button.left-click, .touch-button.right-click { bottom: calc(var(--edge-bottom) + 158px); } + .touch-button.right-click { right: calc(210px + var(--edge-right)); } +} + + +/* Optional compact FPS gameplay layout. */ +.touch-layer.fps-controls { -webkit-user-select: none; user-select: none; } +/* Compact two-thumb HUD, independent of the legacy controller artwork. */ +.touch-layer.fps-controls .touch-button { + width: 46px; height: 46px; min-width: 44px; min-height: 44px; + border: 1px solid #ffffff70; border-radius: 50%; + background: #14202d55; box-shadow: none; + color: #fff; opacity: .78; display: flex; align-items: center; justify-content: center; + font-family: system-ui, sans-serif; +} +.touch-layer.fps-controls .touch-button.pressed { background: #d5ecff55; opacity: 1; } +.touch-layer.fps-controls .glyph { font-size: 23px; font-weight: 500; } +.touch-layer.fps-controls .caption { white-space: nowrap; position: absolute; top: auto; bottom: 4px; margin: 0; font: 600 7px/1 system-ui; letter-spacing: 0; } +.touch-layer.fps-controls .glyph { transform: translateY(-4px); } +.touch-layer.fps-controls .fps-fire { width: 64px; height: 64px; right: calc(82px + env(safe-area-inset-right)); bottom: calc(126px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-fire .glyph { font-size: 36px; } +.touch-layer.fps-controls .fps-jump { right: calc(18px + env(safe-area-inset-right)); bottom: calc(78px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-crouch { right: calc(74px + env(safe-area-inset-right)); bottom: calc(22px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-melee { right: calc(18px + env(safe-area-inset-right)); bottom: calc(22px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-reload { right: calc(134px + env(safe-area-inset-right)); bottom: calc(22px + env(safe-area-inset-bottom)); } +/* Invisible target over the engine's ammo/weapon HUD, including while pressed. */ +.touch-layer.fps-controls .fps-weapon-hud, +.touch-layer.fps-controls .fps-weapon-hud.pressed { + left: max(6vw, env(safe-area-inset-left)); top: max(4vh, env(safe-area-inset-top)); + width: 16vw; height: 16vh; opacity: 0; border: 0; background: transparent; +} +.touch-layer.fps-controls .fps-zoom { right: calc(18px + env(safe-area-inset-right)); bottom: calc(142px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-grenade { left: calc(148px + env(safe-area-inset-left)); bottom: calc(34px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .fps-grenade-type { left: calc(204px + env(safe-area-inset-left)); bottom: calc(34px + env(safe-area-inset-bottom)); } +.touch-layer.fps-controls .touch-stick { opacity: 0; width: 96px; height: 96px; left: calc(28px + env(safe-area-inset-left)); bottom: calc(28px + env(safe-area-inset-bottom)); border: 1px solid #ffffff50; background: #14202d22; box-shadow: none; } +.touch-layer.fps-controls .touch-knob { left: 50%; top: 50%; width: 40px; height: 40px; margin: -20px 0 0 -20px; border: 1px solid #ffffff80; background: #d2e9fa33; box-shadow: none; } + +.touch-layer.fps-controls .touch-stick.active { opacity: 1; } diff --git a/port/web/site/sw.js b/port/web/site/sw.js new file mode 100644 index 0000000000..b046fb9481 --- /dev/null +++ b/port/web/site/sw.js @@ -0,0 +1,146 @@ +/* +SW.JS + +The site's service worker: + +- it serves every page and script with the headers that make the site + cross-origin isolated (Cross-Origin-Opener-Policy and + Cross-Origin-Embedder-Policy), which SharedArrayBuffer, and so the game's + threads, need; static hosts such as GitHub Pages cannot send them; +- it keeps the site's files for use offline, so the installed web app + starts without a network. version.json names the build: the files of one + build are kept together, and a new build replaces them only as a whole + (when the page asks, after the player agrees to update). +*/ + +'use strict'; + +const CACHE_PREFIX = 'halo-web-'; +const META_CACHE = 'halo-web-meta'; +const SHELL = [ + './', + 'index.html', + 'app.js', + 'input.js', + 'net.js', + 'cache.js', + 'vendor/sha256.js', + 'site-config.js', + 'native-invite.js', + 'gateway.js', + 'style.css', + 'xiso-worker.js', + 'audio-worklet.js', + 'halo.js', + 'halo.wasm', + 'manifest.webmanifest', + 'icons/icon-180.png', + 'icons/icon-192.png', + 'icons/icon-512.png', + 'version.json', +]; + +async function networkVersion() { + const response = await fetch('version.json', { cache: 'no-store' }); + return (await response.json()).version; +} + +async function activeVersion() { + const meta = await caches.open(META_CACHE); + const response = await meta.match('active'); + return response ? (await response.json()).version : null; +} + +async function setActiveVersion(version) { + const meta = await caches.open(META_CACHE); + await meta.put('active', new Response(JSON.stringify({ version }))); +} + +// downloads a build's files into a cache of their own, then makes it the +// one served and drops the others +async function installVersion(version) { + const name = CACHE_PREFIX + version; + const cache = await caches.open(name); + await cache.addAll(SHELL.map((path) => new Request(path, { cache: 'no-store' }))); + await setActiveVersion(version); + for (const other of await caches.keys()) { + if (other.startsWith(CACHE_PREFIX) && other !== name && other !== META_CACHE) await caches.delete(other); + } +} + +self.addEventListener('install', (event) => { + event.waitUntil((async () => { + try { + if (!(await activeVersion())) await installVersion(await networkVersion()); + } catch { + // offline or a partial deployment: files come from the network until + // a later start caches them + } + await self.skipWaiting(); + })()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +function isolated(response) { + if (!response || response.status === 0 || response.type === 'opaque') return response; + const headers = new Headers(response.headers); + headers.set('Cross-Origin-Opener-Policy', 'same-origin'); + headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); + headers.set('Cross-Origin-Resource-Policy', 'same-origin'); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +async function cachedResponse(request) { + const version = await activeVersion(); + if (!version) return null; + const cache = await caches.open(CACHE_PREFIX + version); + const url = new URL(request.url); + let response = await cache.match(request, { ignoreSearch: true }); + if (!response && request.mode === 'navigate' && url.pathname.endsWith('/')) { + response = await cache.match('index.html'); + } + return response; +} + +async function respond(request) { + const url = new URL(request.url); + if (url.pathname.endsWith('/version.json') && url.searchParams.has('latest')) { + // the network's, so the page can tell a new build is out + return isolated(await fetch('version.json', { cache: 'no-store' })); + } + const cached = await cachedResponse(request); + if (cached) return isolated(cached); + try { + return isolated(await fetch(request)); + } catch (error) { + if (request.mode === 'navigate') { + const shell = await cachedResponse(new Request('index.html')); + if (shell) return isolated(shell); + } + throw error; + } +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + if (new URL(request.url).origin !== self.location.origin) return; + event.respondWith(respond(request)); +}); + +self.addEventListener('message', (event) => { + if (event.data === 'update') { + event.waitUntil((async () => { + let message = 'updated'; + try { + await installVersion(await networkVersion()); + } catch { + message = 'update-failed'; + } + if (event.source) event.source.postMessage(message); + })()); + } +}); diff --git a/port/web/site/vendor/sha256-LICENSE.txt b/port/web/site/vendor/sha256-LICENSE.txt new file mode 100644 index 0000000000..04e969b47e --- /dev/null +++ b/port/web/site/vendor/sha256-LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014-2025 Chen, Yi-Cyuan + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/port/web/site/vendor/sha256.js b/port/web/site/vendor/sha256.js new file mode 100644 index 0000000000..906663b3e8 --- /dev/null +++ b/port/web/site/vendor/sha256.js @@ -0,0 +1,526 @@ +/** + * [js-sha256]{@link https://github.com/emn178/js-sha256} + * + * @version 0.11.1 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2025 + * @license MIT + */ +/*jslint bitwise: true */ +(function () { + 'use strict'; + + var ERROR = 'input is invalid type'; + var WINDOW = typeof window === 'object'; + var root = WINDOW ? window : {}; + if (root.JS_SHA256_NO_WINDOW) { + WINDOW = false; + } + var WEB_WORKER = !WINDOW && typeof self === 'object'; + var NODE_JS = !root.JS_SHA256_NO_NODE_JS && typeof process === 'object' && process.versions && process.versions.node && process.type != 'renderer'; + if (NODE_JS) { + root = global; + } else if (WEB_WORKER) { + root = self; + } + var COMMON_JS = !root.JS_SHA256_NO_COMMON_JS && typeof module === 'object' && module.exports; + var AMD = typeof define === 'function' && define.amd; + var ARRAY_BUFFER = !root.JS_SHA256_NO_ARRAY_BUFFER && typeof ArrayBuffer !== 'undefined'; + var HEX_CHARS = '0123456789abcdef'.split(''); + var EXTRA = [-2147483648, 8388608, 32768, 128]; + var SHIFT = [24, 16, 8, 0]; + var K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + var OUTPUT_TYPES = ['hex', 'array', 'digest', 'arrayBuffer']; + + var blocks = []; + + if (root.JS_SHA256_NO_NODE_JS || !Array.isArray) { + Array.isArray = function (obj) { + return Object.prototype.toString.call(obj) === '[object Array]'; + }; + } + + if (ARRAY_BUFFER && (root.JS_SHA256_NO_ARRAY_BUFFER_IS_VIEW || !ArrayBuffer.isView)) { + ArrayBuffer.isView = function (obj) { + return typeof obj === 'object' && obj.buffer && obj.buffer.constructor === ArrayBuffer; + }; + } + + var createOutputMethod = function (outputType, is224) { + return function (message) { + return new Sha256(is224, true).update(message)[outputType](); + }; + }; + + var createMethod = function (is224) { + var method = createOutputMethod('hex', is224); + if (NODE_JS) { + method = nodeWrap(method, is224); + } + method.create = function () { + return new Sha256(is224); + }; + method.update = function (message) { + return method.create().update(message); + }; + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createOutputMethod(type, is224); + } + return method; + }; + + var nodeWrap = function (method, is224) { + var crypto = require('crypto') + var Buffer = require('buffer').Buffer; + var algorithm = is224 ? 'sha224' : 'sha256'; + var bufferFrom; + if (Buffer.from && !root.JS_SHA256_NO_BUFFER_FROM) { + bufferFrom = Buffer.from; + } else { + bufferFrom = function (message) { + return new Buffer(message); + }; + } + var nodeMethod = function (message) { + if (typeof message === 'string') { + return crypto.createHash(algorithm).update(message, 'utf8').digest('hex'); + } else { + if (message === null || message === undefined) { + throw new Error(ERROR); + } else if (message.constructor === ArrayBuffer) { + message = new Uint8Array(message); + } + } + if (Array.isArray(message) || ArrayBuffer.isView(message) || + message.constructor === Buffer) { + return crypto.createHash(algorithm).update(bufferFrom(message)).digest('hex'); + } else { + return method(message); + } + }; + return nodeMethod; + }; + + var createHmacOutputMethod = function (outputType, is224) { + return function (key, message) { + return new HmacSha256(key, is224, true).update(message)[outputType](); + }; + }; + + var createHmacMethod = function (is224) { + var method = createHmacOutputMethod('hex', is224); + method.create = function (key) { + return new HmacSha256(key, is224); + }; + method.update = function (key, message) { + return method.create(key).update(message); + }; + for (var i = 0; i < OUTPUT_TYPES.length; ++i) { + var type = OUTPUT_TYPES[i]; + method[type] = createHmacOutputMethod(type, is224); + } + return method; + }; + + function Sha256(is224, sharedMemory) { + if (sharedMemory) { + blocks[0] = blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + this.blocks = blocks; + } else { + this.blocks = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } + + if (is224) { + this.h0 = 0xc1059ed8; + this.h1 = 0x367cd507; + this.h2 = 0x3070dd17; + this.h3 = 0xf70e5939; + this.h4 = 0xffc00b31; + this.h5 = 0x68581511; + this.h6 = 0x64f98fa7; + this.h7 = 0xbefa4fa4; + } else { // 256 + this.h0 = 0x6a09e667; + this.h1 = 0xbb67ae85; + this.h2 = 0x3c6ef372; + this.h3 = 0xa54ff53a; + this.h4 = 0x510e527f; + this.h5 = 0x9b05688c; + this.h6 = 0x1f83d9ab; + this.h7 = 0x5be0cd19; + } + + this.block = this.start = this.bytes = this.hBytes = 0; + this.finalized = this.hashed = false; + this.first = true; + this.is224 = is224; + } + + Sha256.prototype.update = function (message) { + if (this.finalized) { + return; + } + var notString, type = typeof message; + if (type !== 'string') { + if (type === 'object') { + if (message === null) { + throw new Error(ERROR); + } else if (ARRAY_BUFFER && message.constructor === ArrayBuffer) { + message = new Uint8Array(message); + } else if (!Array.isArray(message)) { + if (!ARRAY_BUFFER || !ArrayBuffer.isView(message)) { + throw new Error(ERROR); + } + } + } else { + throw new Error(ERROR); + } + notString = true; + } + var code, index = 0, i, length = message.length, blocks = this.blocks; + while (index < length) { + if (this.hashed) { + this.hashed = false; + blocks[0] = this.block; + this.block = blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + } + + if (notString) { + for (i = this.start; index < length && i < 64; ++index) { + blocks[i >>> 2] |= message[index] << SHIFT[i++ & 3]; + } + } else { + for (i = this.start; index < length && i < 64; ++index) { + code = message.charCodeAt(index); + if (code < 0x80) { + blocks[i >>> 2] |= code << SHIFT[i++ & 3]; + } else if (code < 0x800) { + blocks[i >>> 2] |= (0xc0 | (code >>> 6)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else if (code < 0xd800 || code >= 0xe000) { + blocks[i >>> 2] |= (0xe0 | (code >>> 12)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (message.charCodeAt(++index) & 0x3ff)); + blocks[i >>> 2] |= (0xf0 | (code >>> 18)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | ((code >>> 12) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | ((code >>> 6) & 0x3f)) << SHIFT[i++ & 3]; + blocks[i >>> 2] |= (0x80 | (code & 0x3f)) << SHIFT[i++ & 3]; + } + } + } + + this.lastByteIndex = i; + this.bytes += i - this.start; + if (i >= 64) { + this.block = blocks[16]; + this.start = i - 64; + this.hash(); + this.hashed = true; + } else { + this.start = i; + } + } + if (this.bytes > 4294967295) { + this.hBytes += this.bytes / 4294967296 << 0; + this.bytes = this.bytes % 4294967296; + } + return this; + }; + + Sha256.prototype.finalize = function () { + if (this.finalized) { + return; + } + this.finalized = true; + var blocks = this.blocks, i = this.lastByteIndex; + blocks[16] = this.block; + blocks[i >>> 2] |= EXTRA[i & 3]; + this.block = blocks[16]; + if (i >= 56) { + if (!this.hashed) { + this.hash(); + } + blocks[0] = this.block; + blocks[16] = blocks[1] = blocks[2] = blocks[3] = + blocks[4] = blocks[5] = blocks[6] = blocks[7] = + blocks[8] = blocks[9] = blocks[10] = blocks[11] = + blocks[12] = blocks[13] = blocks[14] = blocks[15] = 0; + } + blocks[14] = this.hBytes << 3 | this.bytes >>> 29; + blocks[15] = this.bytes << 3; + this.hash(); + }; + + Sha256.prototype.hash = function () { + var a = this.h0, b = this.h1, c = this.h2, d = this.h3, e = this.h4, f = this.h5, g = this.h6, + h = this.h7, blocks = this.blocks, j, s0, s1, maj, t1, t2, ch, ab, da, cd, bc; + + for (j = 16; j < 64; ++j) { + // rightrotate + t1 = blocks[j - 15]; + s0 = ((t1 >>> 7) | (t1 << 25)) ^ ((t1 >>> 18) | (t1 << 14)) ^ (t1 >>> 3); + t1 = blocks[j - 2]; + s1 = ((t1 >>> 17) | (t1 << 15)) ^ ((t1 >>> 19) | (t1 << 13)) ^ (t1 >>> 10); + blocks[j] = blocks[j - 16] + s0 + blocks[j - 7] + s1 << 0; + } + + bc = b & c; + for (j = 0; j < 64; j += 4) { + if (this.first) { + if (this.is224) { + ab = 300032; + t1 = blocks[0] - 1413257819; + h = t1 - 150054599 << 0; + d = t1 + 24177077 << 0; + } else { + ab = 704751109; + t1 = blocks[0] - 210244248; + h = t1 - 1521486534 << 0; + d = t1 + 143694565 << 0; + } + this.first = false; + } else { + s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + ab = a & b; + maj = ab ^ (a & c) ^ bc; + ch = (e & f) ^ (~e & g); + t1 = h + s1 + ch + K[j] + blocks[j]; + t2 = s0 + maj; + h = d + t1 << 0; + d = t1 + t2 << 0; + } + s0 = ((d >>> 2) | (d << 30)) ^ ((d >>> 13) | (d << 19)) ^ ((d >>> 22) | (d << 10)); + s1 = ((h >>> 6) | (h << 26)) ^ ((h >>> 11) | (h << 21)) ^ ((h >>> 25) | (h << 7)); + da = d & a; + maj = da ^ (d & b) ^ ab; + ch = (h & e) ^ (~h & f); + t1 = g + s1 + ch + K[j + 1] + blocks[j + 1]; + t2 = s0 + maj; + g = c + t1 << 0; + c = t1 + t2 << 0; + s0 = ((c >>> 2) | (c << 30)) ^ ((c >>> 13) | (c << 19)) ^ ((c >>> 22) | (c << 10)); + s1 = ((g >>> 6) | (g << 26)) ^ ((g >>> 11) | (g << 21)) ^ ((g >>> 25) | (g << 7)); + cd = c & d; + maj = cd ^ (c & a) ^ da; + ch = (g & h) ^ (~g & e); + t1 = f + s1 + ch + K[j + 2] + blocks[j + 2]; + t2 = s0 + maj; + f = b + t1 << 0; + b = t1 + t2 << 0; + s0 = ((b >>> 2) | (b << 30)) ^ ((b >>> 13) | (b << 19)) ^ ((b >>> 22) | (b << 10)); + s1 = ((f >>> 6) | (f << 26)) ^ ((f >>> 11) | (f << 21)) ^ ((f >>> 25) | (f << 7)); + bc = b & c; + maj = bc ^ (b & d) ^ cd; + ch = (f & g) ^ (~f & h); + t1 = e + s1 + ch + K[j + 3] + blocks[j + 3]; + t2 = s0 + maj; + e = a + t1 << 0; + a = t1 + t2 << 0; + this.chromeBugWorkAround = true; + } + + this.h0 = this.h0 + a << 0; + this.h1 = this.h1 + b << 0; + this.h2 = this.h2 + c << 0; + this.h3 = this.h3 + d << 0; + this.h4 = this.h4 + e << 0; + this.h5 = this.h5 + f << 0; + this.h6 = this.h6 + g << 0; + this.h7 = this.h7 + h << 0; + }; + + Sha256.prototype.hex = function () { + this.finalize(); + + var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, + h6 = this.h6, h7 = this.h7; + + var hex = HEX_CHARS[(h0 >>> 28) & 0x0F] + HEX_CHARS[(h0 >>> 24) & 0x0F] + + HEX_CHARS[(h0 >>> 20) & 0x0F] + HEX_CHARS[(h0 >>> 16) & 0x0F] + + HEX_CHARS[(h0 >>> 12) & 0x0F] + HEX_CHARS[(h0 >>> 8) & 0x0F] + + HEX_CHARS[(h0 >>> 4) & 0x0F] + HEX_CHARS[h0 & 0x0F] + + HEX_CHARS[(h1 >>> 28) & 0x0F] + HEX_CHARS[(h1 >>> 24) & 0x0F] + + HEX_CHARS[(h1 >>> 20) & 0x0F] + HEX_CHARS[(h1 >>> 16) & 0x0F] + + HEX_CHARS[(h1 >>> 12) & 0x0F] + HEX_CHARS[(h1 >>> 8) & 0x0F] + + HEX_CHARS[(h1 >>> 4) & 0x0F] + HEX_CHARS[h1 & 0x0F] + + HEX_CHARS[(h2 >>> 28) & 0x0F] + HEX_CHARS[(h2 >>> 24) & 0x0F] + + HEX_CHARS[(h2 >>> 20) & 0x0F] + HEX_CHARS[(h2 >>> 16) & 0x0F] + + HEX_CHARS[(h2 >>> 12) & 0x0F] + HEX_CHARS[(h2 >>> 8) & 0x0F] + + HEX_CHARS[(h2 >>> 4) & 0x0F] + HEX_CHARS[h2 & 0x0F] + + HEX_CHARS[(h3 >>> 28) & 0x0F] + HEX_CHARS[(h3 >>> 24) & 0x0F] + + HEX_CHARS[(h3 >>> 20) & 0x0F] + HEX_CHARS[(h3 >>> 16) & 0x0F] + + HEX_CHARS[(h3 >>> 12) & 0x0F] + HEX_CHARS[(h3 >>> 8) & 0x0F] + + HEX_CHARS[(h3 >>> 4) & 0x0F] + HEX_CHARS[h3 & 0x0F] + + HEX_CHARS[(h4 >>> 28) & 0x0F] + HEX_CHARS[(h4 >>> 24) & 0x0F] + + HEX_CHARS[(h4 >>> 20) & 0x0F] + HEX_CHARS[(h4 >>> 16) & 0x0F] + + HEX_CHARS[(h4 >>> 12) & 0x0F] + HEX_CHARS[(h4 >>> 8) & 0x0F] + + HEX_CHARS[(h4 >>> 4) & 0x0F] + HEX_CHARS[h4 & 0x0F] + + HEX_CHARS[(h5 >>> 28) & 0x0F] + HEX_CHARS[(h5 >>> 24) & 0x0F] + + HEX_CHARS[(h5 >>> 20) & 0x0F] + HEX_CHARS[(h5 >>> 16) & 0x0F] + + HEX_CHARS[(h5 >>> 12) & 0x0F] + HEX_CHARS[(h5 >>> 8) & 0x0F] + + HEX_CHARS[(h5 >>> 4) & 0x0F] + HEX_CHARS[h5 & 0x0F] + + HEX_CHARS[(h6 >>> 28) & 0x0F] + HEX_CHARS[(h6 >>> 24) & 0x0F] + + HEX_CHARS[(h6 >>> 20) & 0x0F] + HEX_CHARS[(h6 >>> 16) & 0x0F] + + HEX_CHARS[(h6 >>> 12) & 0x0F] + HEX_CHARS[(h6 >>> 8) & 0x0F] + + HEX_CHARS[(h6 >>> 4) & 0x0F] + HEX_CHARS[h6 & 0x0F]; + if (!this.is224) { + hex += HEX_CHARS[(h7 >>> 28) & 0x0F] + HEX_CHARS[(h7 >>> 24) & 0x0F] + + HEX_CHARS[(h7 >>> 20) & 0x0F] + HEX_CHARS[(h7 >>> 16) & 0x0F] + + HEX_CHARS[(h7 >>> 12) & 0x0F] + HEX_CHARS[(h7 >>> 8) & 0x0F] + + HEX_CHARS[(h7 >>> 4) & 0x0F] + HEX_CHARS[h7 & 0x0F]; + } + return hex; + }; + + Sha256.prototype.toString = Sha256.prototype.hex; + + Sha256.prototype.digest = function () { + this.finalize(); + + var h0 = this.h0, h1 = this.h1, h2 = this.h2, h3 = this.h3, h4 = this.h4, h5 = this.h5, + h6 = this.h6, h7 = this.h7; + + var arr = [ + (h0 >>> 24) & 0xFF, (h0 >>> 16) & 0xFF, (h0 >>> 8) & 0xFF, h0 & 0xFF, + (h1 >>> 24) & 0xFF, (h1 >>> 16) & 0xFF, (h1 >>> 8) & 0xFF, h1 & 0xFF, + (h2 >>> 24) & 0xFF, (h2 >>> 16) & 0xFF, (h2 >>> 8) & 0xFF, h2 & 0xFF, + (h3 >>> 24) & 0xFF, (h3 >>> 16) & 0xFF, (h3 >>> 8) & 0xFF, h3 & 0xFF, + (h4 >>> 24) & 0xFF, (h4 >>> 16) & 0xFF, (h4 >>> 8) & 0xFF, h4 & 0xFF, + (h5 >>> 24) & 0xFF, (h5 >>> 16) & 0xFF, (h5 >>> 8) & 0xFF, h5 & 0xFF, + (h6 >>> 24) & 0xFF, (h6 >>> 16) & 0xFF, (h6 >>> 8) & 0xFF, h6 & 0xFF + ]; + if (!this.is224) { + arr.push((h7 >>> 24) & 0xFF, (h7 >>> 16) & 0xFF, (h7 >>> 8) & 0xFF, h7 & 0xFF); + } + return arr; + }; + + Sha256.prototype.array = Sha256.prototype.digest; + + Sha256.prototype.arrayBuffer = function () { + this.finalize(); + + var buffer = new ArrayBuffer(this.is224 ? 28 : 32); + var dataView = new DataView(buffer); + dataView.setUint32(0, this.h0); + dataView.setUint32(4, this.h1); + dataView.setUint32(8, this.h2); + dataView.setUint32(12, this.h3); + dataView.setUint32(16, this.h4); + dataView.setUint32(20, this.h5); + dataView.setUint32(24, this.h6); + if (!this.is224) { + dataView.setUint32(28, this.h7); + } + return buffer; + }; + + function HmacSha256(key, is224, sharedMemory) { + var i, type = typeof key; + if (type === 'string') { + var bytes = [], length = key.length, index = 0, code; + for (i = 0; i < length; ++i) { + code = key.charCodeAt(i); + if (code < 0x80) { + bytes[index++] = code; + } else if (code < 0x800) { + bytes[index++] = (0xc0 | (code >>> 6)); + bytes[index++] = (0x80 | (code & 0x3f)); + } else if (code < 0xd800 || code >= 0xe000) { + bytes[index++] = (0xe0 | (code >>> 12)); + bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); + bytes[index++] = (0x80 | (code & 0x3f)); + } else { + code = 0x10000 + (((code & 0x3ff) << 10) | (key.charCodeAt(++i) & 0x3ff)); + bytes[index++] = (0xf0 | (code >>> 18)); + bytes[index++] = (0x80 | ((code >>> 12) & 0x3f)); + bytes[index++] = (0x80 | ((code >>> 6) & 0x3f)); + bytes[index++] = (0x80 | (code & 0x3f)); + } + } + key = bytes; + } else { + if (type === 'object') { + if (key === null) { + throw new Error(ERROR); + } else if (ARRAY_BUFFER && key.constructor === ArrayBuffer) { + key = new Uint8Array(key); + } else if (!Array.isArray(key)) { + if (!ARRAY_BUFFER || !ArrayBuffer.isView(key)) { + throw new Error(ERROR); + } + } + } else { + throw new Error(ERROR); + } + } + + if (key.length > 64) { + key = (new Sha256(is224, true)).update(key).array(); + } + + var oKeyPad = [], iKeyPad = []; + for (i = 0; i < 64; ++i) { + var b = key[i] || 0; + oKeyPad[i] = 0x5c ^ b; + iKeyPad[i] = 0x36 ^ b; + } + + Sha256.call(this, is224, sharedMemory); + + this.update(iKeyPad); + this.oKeyPad = oKeyPad; + this.inner = true; + this.sharedMemory = sharedMemory; + } + HmacSha256.prototype = new Sha256(); + + HmacSha256.prototype.finalize = function () { + Sha256.prototype.finalize.call(this); + if (this.inner) { + this.inner = false; + var innerHash = this.array(); + Sha256.call(this, this.is224, this.sharedMemory); + this.update(this.oKeyPad); + this.update(innerHash); + Sha256.prototype.finalize.call(this); + } + }; + + var exports = createMethod(); + exports.sha256 = exports; + exports.sha224 = createMethod(true); + exports.sha256.hmac = createHmacMethod(); + exports.sha224.hmac = createHmacMethod(true); + + if (COMMON_JS) { + module.exports = exports; + } else { + root.sha256 = exports.sha256; + root.sha224 = exports.sha224; + if (AMD) { + define(function () { + return exports; + }); + } + } +})(); diff --git a/port/web/site/xiso-worker.js b/port/web/site/xiso-worker.js new file mode 100644 index 0000000000..65c3a554e6 --- /dev/null +++ b/port/web/site/xiso-worker.js @@ -0,0 +1,168 @@ +/* +XISO-WORKER.JS + +Copies the maps folder out of an Xbox disc image of Halo: Combat Evolved +(an "xiso", .iso or .xiso) into the site's Origin Private File System, as +port/linux/src/xiso.c does for the desktop ports. It runs in a Worker so +that it can use synchronous OPFS access handles, including in browsers +that do not support OPFS writable streams. + +The file system is XDVDFS: 2048-byte sectors; a volume descriptor at 0x10000 +that starts and ends with "MICROSOFT*XBOX*MEDIA" and gives the root +directory's sector and size; directories whose entries form a binary tree +(left and right subtree offsets in 4-byte units, the start sector, the size, +attributes, the name's length and the name). Images of a whole disc put the +game partition further in, at one of the offsets below (extract-xiso's). + +The page gets messages { type: 'progress', file, done, total }, +{ type: 'done', files, bytes } or { type: 'error', message }. +*/ + +'use strict'; + +const SECTOR_SIZE = 2048; +const VOLUME_DESCRIPTOR_OFFSET = 0x10000; +const PARTITION_OFFSETS = [0, 0x0FD90000, 0x02080000, 0x18300000]; +const MAGIC = 'MICROSOFT*XBOX*MEDIA'; +const ENTRY_HEADER_SIZE = 14; +const ATTRIBUTE_DIRECTORY = 0x10; +const COPY_CHUNK = 4 * 1024 * 1024; +// written last: the page starts the game only when it is there +const COMPLETE_MARKER = '.complete'; + +async function readAt(file, offset, size) { + const end = Math.min(offset + size, file.size); + if (offset >= end) return new Uint8Array(0); + return new Uint8Array(await file.slice(offset, end).arrayBuffer()); +} + +function text(bytes, start, length) { + let result = ''; + for (let i = 0; i < length; i++) result += String.fromCharCode(bytes[start + i]); + return result; +} + +function u32(bytes, at) { + return (bytes[at] | bytes[at + 1] << 8 | bytes[at + 2] << 16 | bytes[at + 3] << 24) >>> 0; +} + +async function findVolume(file) { + for (const partition of PARTITION_OFFSETS) { + const descriptor = await readAt(file, partition + VOLUME_DESCRIPTOR_OFFSET, SECTOR_SIZE); + if (descriptor.length < SECTOR_SIZE) continue; + if (text(descriptor, 0, 20) === MAGIC && text(descriptor, 0x7EC, 20) === MAGIC) { + return { partition, rootSector: u32(descriptor, 20), rootSize: u32(descriptor, 24) }; + } + } + return null; +} + +// the entries of a directory table, files or directories as asked +function walkDirectory(table, wantDirectories) { + const entries = []; + let visited = 0; + const walk = (offset, depth) => { + offset *= 4; + if (depth > 64 || ++visited > 4096 || offset + ENTRY_HEADER_SIZE > table.length) return; + const left = table[offset] | table[offset + 1] << 8; + const right = table[offset + 2] | table[offset + 3] << 8; + if (left === 0xFFFF) return; + const nameLength = table[offset + 13]; + if (left) walk(left, depth + 1); + if (offset + ENTRY_HEADER_SIZE + nameLength <= table.length && nameLength > 0 && + !(table[offset + 12] & ATTRIBUTE_DIRECTORY) === !wantDirectories) { + const name = text(table, offset + ENTRY_HEADER_SIZE, nameLength); + if (name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')) { + entries.push({ name, sector: u32(table, offset + 4), size: u32(table, offset + 8) }); + } + } + if (right) walk(right, depth + 1); + }; + walk(0, 0); + return entries; +} + +async function readDirectory(file, volume, sector, size) { + if (!size || size > 1024 * 1024) return null; + const table = await readAt(file, volume.partition + sector * SECTOR_SIZE, size); + return table.length === size ? table : null; +} + +async function writeFile(directory, name, source, onChunk) { + const handle = await directory.getFileHandle(name, { create: true }); + const access = await handle.createSyncAccessHandle(); + try { + await access.truncate(0); + let position = 0; + while (position < source.size) { + const count = Math.min(COPY_CHUNK, source.size - position); + const bytes = await readAt(source.file, source.offset + position, count); + if (bytes.length !== count) throw new Error(`Could not read ${name} from the disc image (is it complete?).`); + let written = 0; + while (written < count) { + const result = await access.write(bytes.subarray(written), { at: position + written }); + if (!result) throw new Error(`Could not write ${name} (is the device full?).`); + written += result; + } + position += count; + onChunk(count); + } + await access.flush(); + } finally { + await access.close(); + } +} + +async function extract(file) { + const volume = await findVolume(file); + if (!volume) throw new Error('This file is not an Xbox disc image (no XDVDFS volume was found).'); + + const root = await readDirectory(file, volume, volume.rootSector, volume.rootSize); + if (!root) throw new Error("The disc image's file system is damaged."); + const maps = walkDirectory(root, true).find((entry) => entry.name.toLowerCase() === 'maps'); + if (!maps) throw new Error('The disc image has no maps folder: it is not a Halo disc.'); + const table = await readDirectory(file, volume, maps.sector, maps.size); + if (!table) throw new Error("The disc image's maps folder is damaged."); + const files = walkDirectory(table, false); + if (!files.some((entry) => entry.name.toLowerCase() === 'ui.map')) { + throw new Error("The disc image's maps folder has no ui.map: it is not a Halo disc."); + } + + const total = files.reduce((sum, entry) => sum + entry.size, 0); + const storage = await navigator.storage.getDirectory(); + // a new copy replaces whatever an earlier, perhaps interrupted, one left + await storage.removeEntry('maps', { recursive: true }).catch(() => {}); + const directory = await storage.getDirectoryHandle('maps', { create: true }); + let done = 0; + let lastReport = 0; + for (const entry of files) { + await writeFile(directory, entry.name, { + file, + offset: volume.partition + entry.sector * SECTOR_SIZE, + size: entry.size, + }, (count) => { + done += count; + const now = Date.now(); + if (now - lastReport > 100 || done === total) { + lastReport = now; + postMessage({ type: 'progress', file: entry.name, done, total }); + } + }); + } + const marker = await directory.getFileHandle(COMPLETE_MARKER, { create: true }); + const access = await marker.createSyncAccessHandle(); + await access.truncate(0); + await access.write(new TextEncoder().encode(JSON.stringify({ files: files.map((f) => f.name), bytes: total })), { at: 0 }); + await access.flush(); + await access.close(); + return { files: files.length, bytes: total }; +} + +onmessage = async (event) => { + try { + const result = await extract(event.data.file); + postMessage({ type: 'done', ...result }); + } catch (error) { + postMessage({ type: 'error', name: error.name, message: error && error.message ? error.message : String(error) }); + } +}; diff --git a/port/web/src/web_host.c b/port/web/src/web_host.c new file mode 100644 index 0000000000..a0f9a9c452 --- /dev/null +++ b/port/web/src/web_host.c @@ -0,0 +1,84 @@ +/* +WEB_HOST.C + +The services the Android host supplies to the shared OpenGL ES renderer +(port/linux/src/xgpu.h), for WebGL 2. + +The worker recorder snapshots buffer data when it is given, then merges +append-only stream writes before replaying their draws. A frame's commands +are flushed before presentation. The GPU queue is drained once per stream +ring rotation so drivers do not retain an unlimited number of upload copies. +WebGL fences cannot become signaled until the worker yields to its event loop; +this blocking game loop uses a periodic finish instead. +*/ + +#include +#include + +#include "platform.h" + +int host_gl_has_extension(const char *name) +{ + /* WebGL's names for the extensions the renderer asks for */ + static const struct + { + const char *gl; + const char *webgl; + } aliases[] = + { + { "GL_EXT_texture_compression_s3tc", "GL_WEBGL_compressed_texture_s3tc" }, + { "GL_EXT_texture_compression_dxt1", "GL_WEBGL_compressed_texture_s3tc" }, + { "GL_ANGLE_texture_compression_dxt3", "GL_WEBGL_compressed_texture_s3tc" }, + { "GL_ANGLE_texture_compression_dxt5", "GL_WEBGL_compressed_texture_s3tc" }, + { "GL_EXT_texture_filter_anisotropic", "GL_EXT_texture_filter_anisotropic" }, + }; + const char *extensions = (const char *)glGetString(GL_EXTENSIONS); + const char *wanted = name; + size_t index, length; + const char *found; + + if (!extensions || !name) + return 0; + for (index = 0; index < sizeof(aliases) / sizeof(aliases[0]); index++) + { + if (!strcmp(aliases[index].gl, name)) + { + wanted = aliases[index].webgl; + break; + } + } + length = strlen(wanted); + for (found = strstr(extensions, wanted); found; found = strstr(found + 1, wanted)) + { + if ((found == extensions || found[-1] == ' ') && (found[length] == ' ' || found[length] == '\0')) + return 1; + } + return 0; +} + +unsigned int host_gl_read_buffer_word(unsigned int buffer, unsigned int offset) +{ + /* only for atomic counters, which WebGL 2 does not have */ + (void)buffer; + (void)offset; + return 0; +} + +void host_gl_buffer_write(unsigned int target, unsigned int offset, unsigned int size, const void *data) +{ + glBufferSubData((GLenum)target, (GLintptr)offset, (GLsizeiptr)size, data); +} + +void host_gl_fence_frame(unsigned int slot) +{ + (void)slot; +} + +void host_gl_wait_frame(unsigned int slot) +{ + /* Three ring slots (d3d8_gl.c): let frames overlap, then complete their + work before starting another rotation. Waiting happens on the game worker, + not the page/network thread. glFinish is also a recorder replay barrier. */ + if (slot == 0) + glFinish(); +} diff --git a/port/web/src/web_library.js b/port/web/src/web_library.js new file mode 100644 index 0000000000..63eacdbef0 --- /dev/null +++ b/port/web/src/web_library.js @@ -0,0 +1,187 @@ +/* +WEB_LIBRARY.JS + +The JavaScript half of the web runtime (web_sdl.c), which runs on the game's +own thread (a Web Worker). + +The game's WebGL 2 context draws into an OffscreenCanvas of this thread. +The game never returns to the worker's event loop, so frames cannot be +committed to a canvas on the page the usual way: each frame is taken out as +an ImageBitmap (transferToImageBitmap needs no event loop) and posted to the +page, which shows it (Module.haloPresent in port/web/site/app.js). Chrome on +macOS instead receives owned RGBA pixel buffers: its native ImageBitmap +transfer path can crash the renderer when a GPU backing image is missing. +*/ + +addToLibrary({ + $webHalo: { + canvas: null, + streamBatch: null, + flushContext: null, + pendingFrames: null, + readFrame: null, + emptyBitmapReported: false, + // the number the main thread's pthread message handler uses for + // Module[handler](...args) (Emscripten's CMD_CALL_HANDLER) + callHandler: 9, + post(handler, args, transfer) { + if (ENVIRONMENT_IS_PTHREAD) { + postMessage({ cmd: webHalo.callHandler, handler, args }, transfer || []); + } else { + Module[handler]?.(...args); + } + }, + }, + + web_js_gl_create__deps: ['$GL', '$webHalo'], + web_js_gl_create: (width, height, batchStreams, presentAck, pixelFrames) => { + if (typeof OffscreenCanvas == 'undefined') { + webHalo.post('haloMessage', [3, 'This browser cannot draw from a worker (OffscreenCanvas). iOS 17 or later is needed.']); + return 0; + } + var canvas = new OffscreenCanvas(width, height); + var attributes = { + alpha: false, + depth: false, + stencil: false, + antialias: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + powerPreference: 'high-performance', + failIfMajorPerformanceCaveat: false, + }; + var context = canvas.getContext('webgl2', attributes); + if (!context) { + webHalo.post('haloMessage', [3, 'WebGL 2 is not available.']); + return 0; + } + // The shared recorder is bundled into halo.js with --pre-js, including + // the pthread runtime. Native state caches already suppress redundant + // setters; this merges append-only streamed uploads before their draws. + webHalo.flushContext = context.flush.bind(context); + // A separate shared counter lets the page release transferred bitmaps + // even though this worker cannot service acknowledgement messages. Only + // enable it when the launcher advertises support: an already open old + // page can load a newer runtime after a service-worker update. + webHalo.pendingFrames = presentAck ? new Int32Array(new SharedArrayBuffer(4)) : null; + var readback = {}; + if (pixelFrames) { + for (var name of ['getParameter', 'bindFramebuffer', 'bindBuffer', 'pixelStorei', 'readBuffer', 'readPixels']) + readback[name] = context[name].bind(context); + } + // Chrome on macOS can crash its renderer while serializing a GPU-backed + // ImageBitmap from this continuously running worker. Transfer owned RGBA + // bytes instead. Capture the real methods before the stream recorder wraps + // them, and restore every readback state the renderer can observe. + webHalo.readFrame = pixelFrames ? () => { + var width = canvas.width, height = canvas.height; + var pixels = new Uint8Array(width * height * 4); + var framebuffer = readback.getParameter(context.READ_FRAMEBUFFER_BINDING); + var packBuffer = readback.getParameter(context.PIXEL_PACK_BUFFER_BINDING); + var parameters = [context.PACK_ALIGNMENT, context.PACK_ROW_LENGTH, + context.PACK_SKIP_PIXELS, context.PACK_SKIP_ROWS]; + var values = parameters.map(parameter => readback.getParameter(parameter)); + var readBuffer; + try { + readback.bindFramebuffer(context.READ_FRAMEBUFFER, null); + readBuffer = readback.getParameter(context.READ_BUFFER); + readback.readBuffer(context.BACK); + readback.bindBuffer(context.PIXEL_PACK_BUFFER, null); + parameters.forEach((parameter, index) => readback.pixelStorei(parameter, index ? 0 : 1)); + readback.readPixels(0, 0, width, height, context.RGBA, context.UNSIGNED_BYTE, pixels); + } finally { + parameters.forEach((parameter, index) => readback.pixelStorei(parameter, values[index])); + readback.bindBuffer(context.PIXEL_PACK_BUFFER, packBuffer); + if (readBuffer !== undefined) readback.readBuffer(readBuffer); + readback.bindFramebuffer(context.READ_FRAMEBUFFER, framebuffer); + } + // WebGL's default framebuffer starts at the bottom; ImageData starts at + // the top. Reverse rows without allocating another full-sized frame. + var stride = width * 4, row = new Uint8Array(stride); + for (var top = 0, bottom = height - 1; top < bottom; top++, bottom--) { + var first = top * stride, last = bottom * stride; + row.set(pixels.subarray(first, first + stride)); + pixels.copyWithin(first, last, last + stride); + pixels.set(row, last); + } + for (var alpha = 3; alpha < pixels.length; alpha += 4) pixels[alpha] = 255; + return { width, height, pixels: pixels.buffer }; + } : null; + webHalo.streamBatch = batchStreams ? globalThis.HaloStreamBatch.install(context) : null; + canvas.addEventListener?.('webglcontextlost', (event) => { + event.preventDefault(); + webHalo.post('haloMessage', [3, 'The graphics context was lost. Reload the page to continue.']); + }); + webHalo.canvas = canvas; + var handle = GL.registerContext(context, Object.assign({ + majorVersion: 2, + minorVersion: 0, + enableExtensionsByDefault: 1, + }, attributes)); + GL.makeContextCurrent(handle); + return handle; + }, + + web_js_gl_resize__deps: ['$webHalo'], + web_js_gl_resize: (width, height) => { + var canvas = webHalo.canvas; + if (canvas && (canvas.width != width || canvas.height != height)) { + canvas.width = width; + canvas.height = height; + } + }, + + // Hidden quick-play frames still need a replay barrier, without producing + // ImageBitmaps that the suspended page cannot present. + web_js_gl_flush__deps: ['$webHalo'], + web_js_gl_flush: () => { + webHalo.streamBatch?.flush(); + // Hidden frames have no bitmap transfer (the normal implicit GL flush), + // and this worker never yields to its event loop. Submit their commands + // explicitly after replaying the recorder. + webHalo.flushContext?.(); + }, + + web_js_gl_present__deps: ['$webHalo'], + web_js_gl_present: () => { + var canvas = webHalo.canvas; + if (!canvas) return; + // Explicitly flush even when a frame has no final blit. Timers and RAF + // cannot provide this barrier on the continuously running game worker. + webHalo.streamBatch?.flush(); + var pending = webHalo.pendingFrames; + if (pending && Atomics.load(pending, 0) >= 2) { + // The page is busy or suspended. Keep submitting rendered commands, + // without allocating more GPU-backed bitmaps or blocking simulation. + webHalo.flushContext?.(); + return; + } + var bitmap = webHalo.readFrame ? webHalo.readFrame() : canvas.transferToImageBitmap(); + // Chrome can return an empty ImageBitmap after GPU allocation failure. + // Its serializer dereferences the absent backing image; the dimensions + // safely report zero, so discard it before passing a transfer list. + if (!bitmap || !bitmap.width || !bitmap.height) { + bitmap?.close?.(); + webHalo.flushContext?.(); + if (!webHalo.emptyBitmapReported) { + webHalo.emptyBitmapReported = true; + webHalo.post('haloMessage', [0, 'Skipped an empty browser frame before transfer.']); + } + return; + } + if (pending) Atomics.add(pending, 0, 1); + try { + webHalo.post('haloPresent', pending ? [bitmap, pending.buffer] : [bitmap], [bitmap.pixels || bitmap]); + } catch (error) { + if (pending) Atomics.sub(pending, 0, 1); + bitmap.close?.(); + throw error; + } + }, + + // kind: 0 status, 1 notice, 2 clipboard text, 3 fatal error + web_js_post__deps: ['$webHalo'], + web_js_post: (kind, text) => { + webHalo.post('haloMessage', [kind, UTF8ToString(text)]); + }, +}); diff --git a/port/web/src/web_main.c b/port/web/src/web_main.c new file mode 100644 index 0000000000..c08b2eb9a5 --- /dev/null +++ b/port/web/src/web_main.c @@ -0,0 +1,98 @@ +/* +WEB_MAIN.C + +The web build's entry point, and the WebAssembly memory's layout. + +main runs on a pthread (Emscripten's PROXY_TO_PTHREAD), where it may block. +It mounts the site's Origin Private File System, where the page extracted +the game's maps folder (port/web/site/xiso.js) and where the saved games +and config.toml are kept, at /data, then starts the game +(shell_xbox.c's main, renamed halo_game_main by tools/web_build.py). +*/ + +#include +#include +#include +#include +#include +#include +#include "posix.h" + +#include "web_shared.h" + +int halo_game_main(void); +void web_js_post(int kind, const char *text); + +/* ---------- memory + +The Xbox window (port/linux/src/platform.h) is the top 128 MB of the +WebAssembly memory, 0x80000000 to 0x88000000 (tools/web_build.py makes the +memory that size). sbrk grows the C heap up to the heap size this reports, +so reporting the window's start keeps the heap below it. */ + +#define WEB_HEAP_LIMIT 0x80000000UL + +size_t emscripten_get_heap_size(void) +{ + size_t size = (size_t)__builtin_wasm_memory_size(0) << 16; + + return size > WEB_HEAP_LIMIT ? WEB_HEAP_LIMIT : size; +} + +/* ---------- start */ + + +static void set_environment(const char *name, const char *value) +{ + /* the page's choice (Module.arguments) wins */ + setenv(name, value, 0); +} + +int main(int argc, char **argv) +{ + backend_t opfs; + struct web_shared_state *shared = web_shared_state(); + char width[32]; + int index; + + /* --NAME=value arguments from the page become environment variables, + which override config.toml (port/linux/src/port_config.c) */ + for (index = 1; index < argc; index++) + { + if (!strncmp(argv[index], "--", 2) && strchr(argv[index], '=')) + { + char name[128]; + const char *equals = strchr(argv[index], '='); + size_t length = (size_t)(equals - argv[index] - 2); + + if (length < sizeof(name)) + { + memcpy(name, argv[index] + 2, length); + name[length] = '\0'; + setenv(name, equals + 1, 1); + } + } + } + + opfs = wasmfs_create_opfs_backend(); + if (!opfs || wasmfs_create_directory("/data", 0777, opfs) != 0) + { + web_js_post(3, "The browser's private storage (OPFS) cannot be opened."); + return EXIT_FAILURE; + } + posix_make_directory("/data/save"); + + set_environment("HALO_DATA_ROOT", "/data"); + set_environment("HALO_SAVE_ROOT", "/data/save"); + /* no UDP in a browser: system link and internet play stay off */ + set_environment("HALO_NET_ONLINE", "false"); + set_environment("HALO_UPDATE_AUTO", "false"); + /* 480 lines in the shape of the page (d3d8_gl.c screen_mode_choose) */ + if (shared->display_width > 0 && shared->display_height > 0) + { + snprintf(width, sizeof(width), "%d", (int)(480L * shared->display_width / shared->display_height)); + set_environment("HALO_DISPLAY_WIDTH", width); + } + web_js_post(0, "starting"); + return halo_game_main(); +} diff --git a/port/web/src/web_memory_watch.c b/port/web/src/web_memory_watch.c new file mode 100644 index 0000000000..7b7fa632e4 --- /dev/null +++ b/port/web/src/web_memory_watch.c @@ -0,0 +1,137 @@ +/* +WEB_MEMORY_WATCH.C + +Write tracking for guest memory that the renderer caches +(port/linux/src/memory_watch.c's interface). + +WebAssembly memory has no page protection, so a write cannot fault. A +watched page instead remembers a hash of its contents: asking for its +generation hashes it again, and a different hash counts as a write, as the +fault would have. Writes the file layer announces (memory_watch_prepare_write) +count at once. The renderer only asks about the pages its draws read, so +the pages hashed each frame are about the ones the GPU reads anyway. +*/ + +#include "platform.h" + +#include +#include + +#define WATCH_PAGE_SIZE 0x1000UL +#define WATCH_PAGE_COUNT (PLATFORM_CONTIGUOUS_SIZE / WATCH_PAGE_SIZE) + +static unsigned char page_protected[WATCH_PAGE_COUNT]; +static unsigned long page_generation[WATCH_PAGE_COUNT]; +static uint64_t page_hash[WATCH_PAGE_COUNT]; +static volatile unsigned long current_generation = 1; + +static unsigned long page_index(unsigned long address) +{ + return (address - PLATFORM_CONTIGUOUS_BASE) / WATCH_PAGE_SIZE; +} + +static uint64_t hash_page(unsigned long page) +{ + const uint64_t *words = (const uint64_t *)(PLATFORM_CONTIGUOUS_BASE + page * WATCH_PAGE_SIZE); + uint64_t a = 0x9e3779b97f4a7c15ULL, b = 0xc2b2ae3d27d4eb4fULL; + uint64_t c = 0x165667b19e3779f9ULL, d = 0x27d4eb2f165667c5ULL; + unsigned long index; + + /* four independent lanes, so the loop vectorises */ + for (index = 0; index < WATCH_PAGE_SIZE / sizeof(uint64_t); index += 4) + { + a = (a ^ words[index + 0]) * 0x100000001b3ULL; + b = (b ^ words[index + 1]) * 0x100000001b3ULL; + c = (c ^ words[index + 2]) * 0x100000001b3ULL; + d = (d ^ words[index + 3]) * 0x100000001b3ULL; + } + return a ^ (b << 1 | b >> 63) ^ (c << 7 | c >> 57) ^ (d << 13 | d >> 51); +} + +static void mark_written(unsigned long page) +{ + page_generation[page] = __sync_add_and_fetch(¤t_generation, 1); + page_protected[page] = 0; +} + +static BOOL page_range(unsigned long address, unsigned long size, unsigned long *first, unsigned long *last) +{ + unsigned long start = address, end = address + size; + + if (!size || end <= PLATFORM_CONTIGUOUS_BASE || start >= PLATFORM_CONTIGUOUS_BASE + PLATFORM_CONTIGUOUS_SIZE) + return FALSE; + if (start < PLATFORM_CONTIGUOUS_BASE) + start = PLATFORM_CONTIGUOUS_BASE; + if (end > PLATFORM_CONTIGUOUS_BASE + PLATFORM_CONTIGUOUS_SIZE) + end = PLATFORM_CONTIGUOUS_BASE + PLATFORM_CONTIGUOUS_SIZE; + *first = page_index(start); + *last = page_index(end - 1); + return TRUE; +} + +void memory_watch_initialize(void) +{ +} + +void memory_watch_protect(unsigned long address, unsigned long size) +{ + unsigned long first, last, page; + + if (!page_range(address, size, &first, &last)) + return; + for (page = first; page <= last; page++) + { + if (!page_protected[page]) + { + page_hash[page] = hash_page(page); + page_protected[page] = 1; + } + } +} + +unsigned long memory_watch_generation(unsigned long address, unsigned long size) +{ + unsigned long first, last, page, newest = 0; + + if (!page_range(address, size, &first, &last)) + return 0; + for (page = first; page <= last; page++) + { + if (page_protected[page] && hash_page(page) != page_hash[page]) + mark_written(page); + if (page_generation[page] > newest) + newest = page_generation[page]; + } + return newest; +} + +unsigned long memory_watch_serial(void) +{ + return current_generation; +} + +void memory_watch_prepare_write(void *address, unsigned long size) +{ + unsigned long first, last, page; + + if (!page_range((unsigned long)address, size, &first, &last)) + return; + for (page = first; page <= last; page++) + { + if (page_protected[page]) + mark_written(page); + } +} + +void memory_watch_forget(void *address, unsigned long size) +{ + unsigned long first, last, page; + + if (!page_range((unsigned long)address, size, &first, &last)) + return; + for (page = first; page <= last; page++) + { + page_protected[page] = 0; + page_generation[page] = __sync_add_and_fetch(¤t_generation, 1); + } +} diff --git a/port/web/src/web_net.c b/port/web/src/web_net.c new file mode 100644 index 0000000000..f687e073cf --- /dev/null +++ b/port/web/src/web_net.c @@ -0,0 +1,1331 @@ +/* +WEB_NET.C + +The sockets of port/linux/src/posix.h for the web build: a network inside +the page. + +Browsers have no UDP or plain TCP, but the game needs sockets even when it +plays alone: a split screen game is a network game whose host and clients +are the same machine, connected through Winsock (transport_endpoint_winsock.c). +Here every socket belongs to one machine with two addresses, loopback and +its address on the players' network (10.x.y.z, which the page chooses). +Datagrams sent to either, or to a broadcast address, go to the socket bound +to their port; stream sockets connect to the listening socket of their port +and exchange bytes through queues. + +Online, the other players' machines have addresses on the same network. +Datagrams to them (and broadcasts) and streams connected to them become +packets in a ring the page reads (web_shared.h); the page carries them over +WebRTC to the other players' pages (port/web/site/net.js), which put them in +their games' incoming rings, from which the socket calls here take them. +System link's own discovery and connections then work between the players. + +As with Winsock, each call returns -1 on failure with the error code in +posix_socket_last_error(). +*/ + +#include +#include +#include +#include +#include +#include + +#include "posix.h" +#include "web_shared.h" +#include "web_ping.h" + +static unsigned int ping_address(unsigned int native) +{ + return (native >> 24) | ((native >> 8) & 0xff00u) | + ((native << 8) & 0xff0000u) | (native << 24); +} + +static int web_net_host_ping_at(unsigned int address, unsigned int host, + unsigned int epoch, unsigned int now) +{ + struct web_shared_state *shared = web_shared_state(); + unsigned int sequence = __atomic_load_n(&shared->ping_sequence, __ATOMIC_SEQ_CST); + int count, result = -1, index; + if (!sequence || (sequence & 1) || !address || !host) + return -1; + if ((unsigned int)__atomic_load_n(&shared->ping_host, __ATOMIC_SEQ_CST) != ping_address(host) || + (unsigned int)__atomic_load_n(&shared->ping_epoch, __ATOMIC_SEQ_CST) != epoch || + now - (unsigned int)__atomic_load_n(&shared->ping_updated, __ATOMIC_SEQ_CST) >= WEB_PING_STALE_MS) + return -1; + count = __atomic_load_n(&shared->ping_count, __ATOMIC_SEQ_CST); + if (count < 0 || count > WEB_PING_PEERS) + return -1; + for (index = 0; index < count; index++) + { + if ((unsigned int)__atomic_load_n(&shared->ping_peers[index][0], __ATOMIC_SEQ_CST) == ping_address(address)) + result = __atomic_load_n(&shared->ping_peers[index][1], __ATOMIC_SEQ_CST); + } + if (sequence != (unsigned int)__atomic_load_n(&shared->ping_sequence, __ATOMIC_SEQ_CST) || + result < 0 || result >= WEB_PING_STALE_MS) + return -1; + return result; +} + +int web_net_host_ping(unsigned int address, unsigned int host, unsigned int epoch) +{ + struct timespec now; + clock_gettime(CLOCK_REALTIME, &now); + return web_net_host_ping_at(address, host, epoch, + (unsigned int)((unsigned long long)now.tv_sec * 1000 + now.tv_nsec / 1000000)); +} + +/* Winsock error codes (winerror.h) */ +#define WSAEBADF 10009 +#define WSAEFAULT 10014 +#define WSAEINVAL 10022 +#define WSAEMFILE 10024 +#define WSAEWOULDBLOCK 10035 +#define WSAENOTSOCK 10038 +#define WSAEMSGSIZE 10040 +#define WSAEPROTONOSUPPORT 10043 +#define WSAEOPNOTSUPP 10045 +#define WSAEAFNOSUPPORT 10047 +#define WSAEADDRINUSE 10048 +#define WSAECONNRESET 10054 +#define WSAENOBUFS 10055 +#define WSAEISCONN 10056 +#define WSAENOTCONN 10057 +#define WSAECONNREFUSED 10061 + +#define AF_INET_VALUE 2 +#define SOCK_STREAM_VALUE 1 +#define SOCK_DGRAM_VALUE 2 + +/* 10.0.2.15, in network byte order: the machine's address until the page +gives it one */ +#define WEB_DEFAULT_ADDRESS 0x0F02000AUL +#define WEB_LOCAL_ADDRESS local_address() +#define LOOPBACK_ADDRESS 0x0100007FUL +/* the largest payload of one packet to another machine */ +#define MAXIMUM_LINK_PAYLOAD 16000 +/* waits look at the incoming packets this often */ +#define LINK_POLL_NANOSECONDS 4000000L + +#define SOCKET_BASE 1000 /* descriptors apart from the file system's */ +#define MAXIMUM_SOCKETS 256 +#define MAXIMUM_DATAGRAMS 512 +#define MAXIMUM_DATAGRAM 65536 +#define STREAM_CAPACITY (1024 * 1024) +#define MAXIMUM_BACKLOG 16 +#define FIRST_EPHEMERAL_PORT 49152 + +/* sockaddr_in as Winsock and BSD share it */ +struct address +{ + unsigned short family; + unsigned short port; /* network byte order */ + unsigned int ip; /* network byte order */ + unsigned char zero[8]; +}; + +struct datagram +{ + struct datagram *next; + struct address from; + int length; + unsigned char data[]; +}; + +struct web_socket +{ + int used; + int type; + int nonblocking; + int bound, listening, connected, peer_closed, shut_down; + struct address local, remote; + /* datagrams */ + struct datagram *first, *last; + int datagram_count; + /* a stream's received bytes (a ring) */ + unsigned char *stream; + int stream_head, stream_count; + int peer; /* the connected socket's index, or -1 */ + int remote_link; /* a stream to another machine (through the page) */ + /* a listener's connections waiting to be accepted */ + int backlog[MAXIMUM_BACKLOG]; + int backlog_count; +}; + +static struct web_socket sockets[MAXIMUM_SOCKETS]; +static pthread_mutex_t network_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t network_changed = PTHREAD_COND_INITIALIZER; +static unsigned short next_ephemeral_port = FIRST_EPHEMERAL_PORT; +static __thread int last_error; + +static unsigned short swap16(unsigned short value) +{ + return (unsigned short)((value >> 8) | (value << 8)); +} + +/* HALO_NET_DEBUG=1 logs the network's traffic */ +static int trace_enabled(void) +{ + static int enabled = -1; + + if (enabled < 0) + enabled = getenv("HALO_NET_DEBUG") != NULL; + return enabled; +} + +#define TRACE(...) do { if (trace_enabled()) fprintf(stderr, "web_net: " __VA_ARGS__); } while (0) + +static int fail(int error) +{ + last_error = error; + if (error != WSAEWOULDBLOCK) + TRACE("error %d\n", error); + return -1; +} + +static int succeed(int result) +{ + last_error = 0; + return result; +} + +/* the socket of a descriptor, with the lock held; NULL (and the error) if +there is none */ +static struct web_socket *socket_get(int descriptor) +{ + int index = descriptor - SOCKET_BASE; + + if (index < 0 || index >= MAXIMUM_SOCKETS || !sockets[index].used) + { + last_error = WSAENOTSOCK; + TRACE("no socket %d\n", descriptor); + return NULL; + } + return &sockets[index]; +} + +static unsigned int local_address(void) +{ + unsigned int address = (unsigned int)web_shared_state()->net_local_address; + + return address ? address : WEB_DEFAULT_ADDRESS; +} + +static int is_broadcast(unsigned int ip) +{ + return ip == 0xFFFFFFFFu || (unsigned char)(ip >> 24) == 255; +} + +/* this machine's addresses (a broadcast reaches it too) */ +static int is_local(unsigned int ip) +{ + return ip == 0 || ip == LOOPBACK_ADDRESS || ip == WEB_LOCAL_ADDRESS || is_broadcast(ip) || (ip & 0xFF) == 127; +} + +static int port_in_use(int type, unsigned short port) +{ + int index; + + for (index = 0; index < MAXIMUM_SOCKETS; index++) + { + if (sockets[index].used && sockets[index].bound && sockets[index].type == type && + sockets[index].local.port == port && !(type == SOCK_STREAM_VALUE && sockets[index].connected && + !sockets[index].listening)) + { + return 1; + } + } + return 0; +} + +static unsigned short ephemeral_port(int type) +{ + int tries; + + for (tries = 0; tries < 16384; tries++) + { + unsigned short port = swap16(next_ephemeral_port); + + next_ephemeral_port = next_ephemeral_port == 65535 ? FIRST_EPHEMERAL_PORT : next_ephemeral_port + 1; + if (!port_in_use(type, port)) + return port; + } + return 0; +} + +static int is_loopback(unsigned int ip) +{ + return (ip & 0xFF) == 127; +} + +/* binds an unbound socket to any address and a free port */ +static void bind_implicitly(struct web_socket *socket) +{ + if (!socket->bound) + { + memset(&socket->local, 0, sizeof(socket->local)); + socket->local.family = AF_INET_VALUE; + socket->local.ip = 0; + socket->local.port = ephemeral_port(socket->type); + socket->bound = 1; + } +} + +/* the address a packet to destination comes from, as a host's routing +would choose it for a socket bound to any address */ +static struct address source_address(const struct web_socket *socket, unsigned int destination) +{ + struct address source = socket->local; + + if (!source.ip) + source.ip = is_loopback(destination) ? LOOPBACK_ADDRESS : WEB_LOCAL_ADDRESS; + return source; +} + +static void datagrams_free(struct web_socket *socket) +{ + while (socket->first) + { + struct datagram *next = socket->first->next; + + free(socket->first); + socket->first = next; + } + socket->last = NULL; + socket->datagram_count = 0; +} + +/* ---------- packets to and from other machines (web_shared.h) */ + +static void ring_copy_in(unsigned char *ring, unsigned int capacity, unsigned int position, const void *data, + unsigned int length) +{ + unsigned int start = position & (capacity - 1); + unsigned int first = capacity - start < length ? capacity - start : length; + + memcpy(ring + start, data, first); + memcpy(ring, (const unsigned char *)data + first, length - first); +} + +static void ring_copy_out(const unsigned char *ring, unsigned int capacity, unsigned int position, void *data, + unsigned int length) +{ + unsigned int start = position & (capacity - 1); + unsigned int first = capacity - start < length ? capacity - start : length; + + memcpy(data, ring + start, first); + memcpy((unsigned char *)data + first, ring, length - first); +} + +/* queues a packet for the page to send; 0 if the ring is full */ +static int link_send(unsigned int kind, const struct address *from, const struct address *to, const void *payload, + unsigned int length) +{ + struct web_shared_state *shared = web_shared_state(); + struct web_packet_header header; + unsigned int write = (unsigned int)__atomic_load_n(&shared->net_out_write, __ATOMIC_SEQ_CST); + unsigned int read = (unsigned int)__atomic_load_n(&shared->net_out_read, __ATOMIC_SEQ_CST); + unsigned int size = (unsigned int)(sizeof(header) + length + 3) & ~3u; + + if (WEB_NET_OUT_BYTES - (write - read) < size) + return 0; + header.size = size; + header.kind = kind; + header.source_ip = from->ip; + header.destination_ip = to->ip; + header.source_port = from->port; + header.destination_port = to->port; + header.length = length; + ring_copy_in(shared->net_out, WEB_NET_OUT_BYTES, write, &header, sizeof(header)); + if (length) + ring_copy_in(shared->net_out, WEB_NET_OUT_BYTES, write + (unsigned int)sizeof(header), payload, length); + __atomic_store_n(&shared->net_out_write, (int32_t)(write + size), __ATOMIC_SEQ_CST); + return 1; +} + +static int free_slot(void) +{ + int index; + + for (index = 0; index < MAXIMUM_SOCKETS && sockets[index].used; index++) + ; + return index < MAXIMUM_SOCKETS ? index : -1; +} + +static int find_listener(unsigned short port); +static void socket_release(int index); +static void deliver_local_datagram(const struct address *from, const struct address *to, const void *buffer, + int length); + +/* the stream socket for a connection with another machine */ +static struct web_socket *remote_stream(unsigned int local_port, unsigned int remote_ip, unsigned int remote_port) +{ + int index; + + for (index = 0; index < MAXIMUM_SOCKETS; index++) + { + struct web_socket *socket = &sockets[index]; + + if (socket->used && socket->remote_link && !socket->peer_closed && socket->local.port == local_port && + socket->remote.ip == remote_ip && socket->remote.port == remote_port) + { + return socket; + } + } + return NULL; +} + +/* A reloaded page keeps its address but can reuse the same ephemeral port. + Its OPEN replaces that connection; the game's old descriptor must see EOF + without routing bytes or a later CLOSE to the replacement. */ +static int replace_remote_stream(int listener, const struct address *from, const struct address *to) +{ + int index, waiting_for_release = 0; + + for (index = 0; index < MAXIMUM_SOCKETS; index++) + { + struct web_socket *socket = &sockets[index]; + int entry; + + if (!socket->used || !socket->remote_link || socket->local.port != to->port || + socket->remote.ip != from->ip || socket->remote.port != from->port) + continue; + socket->peer_closed = 1; + socket->stream_head = socket->stream_count = 0; + free(socket->stream); + socket->stream = NULL; + /* An unaccepted connection has no game-owned descriptor to retain. */ + for (entry = 0; entry < sockets[listener].backlog_count; entry++) + { + if (sockets[listener].backlog[entry] == index) + { + memmove(sockets[listener].backlog + entry, sockets[listener].backlog + entry + 1, + (size_t)(--sockets[listener].backlog_count - entry) * sizeof(int)); + socket_release(index); + break; + } + } + if (socket->used) + waiting_for_release = 1; + } + /* The game must remove the old endpoint before it can accept the new one. + Keep OPEN queued until the descriptor owner observes EOF and closes it. */ + if (waiting_for_release) + pthread_cond_broadcast(&network_changed); + return !waiting_for_release; +} + +/* 0 leaves the complete packet in the shared ring until recv makes room or + the game releases a replaced stream's old descriptor. */ +static int link_receive(const struct web_packet_header *header, const unsigned char *payload) +{ + struct address from, to; + struct web_socket *socket; + + memset(&from, 0, sizeof(from)); + memset(&to, 0, sizeof(to)); + from.family = to.family = AF_INET_VALUE; + from.ip = header->source_ip; + from.port = header->source_port; + to.ip = header->destination_ip; + to.port = header->destination_port; + switch (header->kind) + { + case WEB_PACKET_DATAGRAM: + deliver_local_datagram(&from, &to, payload, (int)header->length); + break; + case WEB_PACKET_OPEN: + { + int listener = find_listener(to.port); + int server; + + if (listener >= 0 && !replace_remote_stream(listener, &from, &to)) + return 0; + server = free_slot(); + + if (listener < 0 || server < 0 || sockets[listener].backlog_count == MAXIMUM_BACKLOG) + { + struct address local = to; + + local.ip = WEB_LOCAL_ADDRESS; + link_send(WEB_PACKET_REFUSE, &local, &from, NULL, 0); + TRACE("refused a connection from %08x:%u to port %u\n", from.ip, swap16(from.port), swap16(to.port)); + break; + } + socket = &sockets[server]; + memset(socket, 0, sizeof(*socket)); + socket->used = 1; + socket->type = SOCK_STREAM_VALUE; + socket->bound = 1; + socket->connected = 1; + socket->remote_link = 1; + socket->peer = -1; + socket->local = to; + socket->local.ip = WEB_LOCAL_ADDRESS; + socket->remote = from; + socket->stream = malloc(STREAM_CAPACITY); + sockets[listener].backlog[sockets[listener].backlog_count++] = server; + TRACE("connection from %08x:%u to port %u\n", from.ip, swap16(from.port), swap16(to.port)); + break; + } + case WEB_PACKET_DATA: + socket = remote_stream(to.port, from.ip, from.port); + if (socket && socket->stream) + { + unsigned int index; + + if (header->length > (unsigned int)(STREAM_CAPACITY - socket->stream_count)) + return 0; + for (index = 0; index < header->length; index++) + { + socket->stream[(socket->stream_head + socket->stream_count) % STREAM_CAPACITY] = payload[index]; + socket->stream_count++; + } + } + break; + case WEB_PACKET_CLOSE: + case WEB_PACKET_REFUSE: + socket = remote_stream(to.port, from.ip, from.port); + if (socket) + socket->peer_closed = 1; + break; + default: + break; + } + return 1; +} + +/* takes the packets the page received from other machines; with the lock +held */ +static void link_pump(void) +{ + static unsigned char *payload; + struct web_shared_state *shared = web_shared_state(); + unsigned int read = (unsigned int)__atomic_load_n(&shared->net_in_read, __ATOMIC_SEQ_CST); + unsigned int write = (unsigned int)__atomic_load_n(&shared->net_in_write, __ATOMIC_SEQ_CST); + int any = 0; + + if (read == write) + return; + if (!payload) + payload = malloc(MAXIMUM_DATAGRAM + 16); + while (read != write && payload) + { + struct web_packet_header header; + + ring_copy_out(shared->net_in, WEB_NET_IN_BYTES, read, &header, sizeof(header)); + if (header.size < sizeof(header) || header.size > WEB_NET_IN_BYTES || header.length > MAXIMUM_DATAGRAM) + { + /* a damaged ring: start over */ + read = write; + break; + } + if (header.length) + ring_copy_out(shared->net_in, WEB_NET_IN_BYTES, read + (unsigned int)sizeof(header), payload, header.length); + if (!link_receive(&header, payload)) + break; + read += header.size; + any = 1; + } + __atomic_store_n(&shared->net_in_read, (int32_t)read, __ATOMIC_SEQ_CST); + if (any) + pthread_cond_broadcast(&network_changed); +} + +/* waits for a change here or a packet from another machine; with the lock +held */ +static void wait_briefly(void) +{ + struct timespec deadline; + + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_nsec += LINK_POLL_NANOSECONDS; + if (deadline.tv_nsec >= 1000000000L) + { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000L; + } + pthread_cond_timedwait(&network_changed, &network_lock, &deadline); + link_pump(); +} + +static void socket_release(int index) +{ + struct web_socket *socket = &sockets[index]; + int entry; + + datagrams_free(socket); + free(socket->stream); + if (socket->remote_link && !socket->peer_closed) + link_send(WEB_PACKET_CLOSE, &socket->local, &socket->remote, NULL, 0); + if (socket->peer >= 0 && sockets[socket->peer].used && sockets[socket->peer].peer == index) + { + sockets[socket->peer].peer_closed = 1; + sockets[socket->peer].peer = -1; + } + for (entry = 0; entry < socket->backlog_count; entry++) + socket_release(socket->backlog[entry]); + memset(socket, 0, sizeof(*socket)); +} + +/* ---------- posix.h */ + +int posix_socket_last_error(void) +{ + return last_error; +} + +int posix_socket(int family, int type, int protocol) +{ + int index; + + (void)protocol; + if (family != AF_INET_VALUE) + return fail(WSAEAFNOSUPPORT); + if (type != SOCK_STREAM_VALUE && type != SOCK_DGRAM_VALUE) + return fail(WSAEPROTONOSUPPORT); + pthread_mutex_lock(&network_lock); + for (index = 0; index < MAXIMUM_SOCKETS && sockets[index].used; index++) + ; + if (index == MAXIMUM_SOCKETS) + { + pthread_mutex_unlock(&network_lock); + return fail(WSAEMFILE); + } + memset(&sockets[index], 0, sizeof(sockets[index])); + sockets[index].used = 1; + sockets[index].type = type; + sockets[index].peer = -1; + pthread_mutex_unlock(&network_lock); + return succeed(SOCKET_BASE + index); +} + +int posix_socket_close(int descriptor) +{ + struct web_socket *socket; + + TRACE("close %d\n", descriptor); + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + socket_release(descriptor - SOCKET_BASE); + pthread_cond_broadcast(&network_changed); + pthread_mutex_unlock(&network_lock); + return socket ? succeed(0) : -1; +} + +int posix_socket_bind(int descriptor, const void *address, int address_length) +{ + struct web_socket *socket; + struct address wanted; + int result = -1; + + if (!address || address_length < (int)sizeof(struct address) - 8) + return fail(WSAEFAULT); + memcpy(&wanted, address, sizeof(wanted) - 8); + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (socket->bound) + result = fail(WSAEINVAL); + else if (wanted.port && port_in_use(socket->type, wanted.port)) + result = fail(WSAEADDRINUSE); + else + { + memset(&socket->local, 0, sizeof(socket->local)); + socket->local.family = AF_INET_VALUE; + socket->local.ip = wanted.ip; + socket->local.port = wanted.port ? wanted.port : ephemeral_port(socket->type); + socket->bound = 1; + TRACE("bind %d (type %d) to %08x:%u\n", descriptor, socket->type, socket->local.ip, swap16(socket->local.port)); + result = succeed(0); + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +int posix_socket_listen(int descriptor, int backlog) +{ + struct web_socket *socket; + int result = -1; + + (void)backlog; + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (socket->type != SOCK_STREAM_VALUE) + result = fail(WSAEOPNOTSUPP); + else + { + bind_implicitly(socket); + socket->listening = 1; + result = succeed(0); + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +static int find_listener(unsigned short port) +{ + int index; + + for (index = 0; index < MAXIMUM_SOCKETS; index++) + { + if (sockets[index].used && sockets[index].listening && sockets[index].local.port == port) + return index; + } + return -1; +} + +int posix_socket_connect(int descriptor, const void *address, int address_length) +{ + struct web_socket *socket; + struct address target; + int result = -1; + + if (!address || address_length < (int)sizeof(struct address) - 8) + return fail(WSAEFAULT); + memcpy(&target, address, sizeof(target) - 8); + TRACE("connect %d to %08x:%u\n", descriptor, target.ip, swap16(target.port)); + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + int client = descriptor - SOCKET_BASE; + + bind_implicitly(socket); + if (socket->type == SOCK_STREAM_VALUE && !socket->local.ip) + socket->local = source_address(socket, target.ip); + memset(&socket->remote, 0, sizeof(socket->remote)); + socket->remote.family = AF_INET_VALUE; + socket->remote.ip = target.ip; + socket->remote.port = target.port; + if (socket->type == SOCK_DGRAM_VALUE) + { + socket->connected = 1; + result = succeed(0); + } + else if (socket->connected) + { + result = fail(WSAEISCONN); + } + else if (!is_local(target.ip)) + { + /* another machine's: through the page */ + socket->stream = malloc(STREAM_CAPACITY); + socket->remote_link = 1; + socket->connected = 1; + if (!socket->stream || !link_send(WEB_PACKET_OPEN, &socket->local, &socket->remote, NULL, 0)) + result = fail(WSAENOBUFS); + else if (socket->nonblocking) + result = fail(WSAEWOULDBLOCK); + else + result = succeed(0); + } + else + { + int listener = is_local(target.ip) ? find_listener(target.port) : -1; + int server; + + for (server = 0; server < MAXIMUM_SOCKETS && sockets[server].used; server++) + ; + if (listener < 0) + result = fail(WSAECONNREFUSED); + else if (server == MAXIMUM_SOCKETS || sockets[listener].backlog_count == MAXIMUM_BACKLOG) + result = fail(WSAENOBUFS); + else + { + /* the listener's end of the connection, until accepted */ + struct web_socket *end = &sockets[server]; + + memset(end, 0, sizeof(*end)); + end->used = 1; + end->type = SOCK_STREAM_VALUE; + end->bound = 1; + end->connected = 1; + end->local = sockets[listener].local; + end->local.ip = is_loopback(target.ip) ? LOOPBACK_ADDRESS : WEB_LOCAL_ADDRESS; + end->remote = socket->local; + end->peer = client; + end->stream = malloc(STREAM_CAPACITY); + socket->stream = malloc(STREAM_CAPACITY); + socket->peer = server; + socket->connected = 1; + sockets[listener].backlog[sockets[listener].backlog_count++] = server; + pthread_cond_broadcast(&network_changed); + /* Winsock's non-blocking connect: under way, then writeable + (transport_endpoint_winsock.c waits for that) */ + if (socket->nonblocking) + result = fail(WSAEWOULDBLOCK); + else + result = succeed(0); + } + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +static int wait_changed(struct web_socket *socket) +{ + if (socket->nonblocking) + return 0; + wait_briefly(); + return 1; +} + +int posix_socket_accept(int descriptor, void *address, int *address_length) +{ + struct web_socket *socket; + int result = -1; + + pthread_mutex_lock(&network_lock); + link_pump(); + for (;;) + { + socket = socket_get(descriptor); + if (!socket) + break; + if (!socket->listening) + { + result = fail(WSAEINVAL); + break; + } + if (socket->backlog_count) + { + int accepted = socket->backlog[0]; + + memmove(socket->backlog, socket->backlog + 1, (size_t)--socket->backlog_count * sizeof(int)); + if (address && address_length && *address_length >= (int)sizeof(struct address)) + { + memcpy(address, &sockets[accepted].remote, sizeof(struct address)); + *address_length = (int)sizeof(struct address); + } + TRACE("accept %d -> %d\n", descriptor, SOCKET_BASE + accepted); + result = succeed(SOCKET_BASE + accepted); + break; + } + if (!wait_changed(socket)) + { + result = fail(WSAEWOULDBLOCK); + break; + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +static int stream_send(struct web_socket *socket, const unsigned char *buffer, int length) +{ + struct web_socket *peer; + int written = 0; + + TRACE("send %d bytes on %d (connected %d peer %d closed %d)\n", length, (int)(socket - sockets) + SOCKET_BASE, + socket->connected, socket->peer, socket->peer_closed); + if (!socket->connected) + return fail(WSAENOTCONN); + if (socket->remote_link) + { + for (;;) + { + if (socket->peer_closed) + return fail(WSAECONNRESET); + while (written < length) + { + int chunk = length - written > MAXIMUM_LINK_PAYLOAD ? MAXIMUM_LINK_PAYLOAD : length - written; + + if (!link_send(WEB_PACKET_DATA, &socket->local, &socket->remote, buffer + written, (unsigned int)chunk)) + break; + written += chunk; + } + if (written == length || (written && socket->nonblocking)) + return succeed(written); + if (!wait_changed(socket)) + return fail(WSAEWOULDBLOCK); + } + } + for (;;) + { + if (socket->peer < 0 || socket->peer_closed) + return fail(WSAECONNRESET); + peer = &sockets[socket->peer]; + while (written < length && peer->stream_count < STREAM_CAPACITY) + { + peer->stream[(peer->stream_head + peer->stream_count) % STREAM_CAPACITY] = buffer[written++]; + peer->stream_count++; + } + pthread_cond_broadcast(&network_changed); + if (written == length || (written && socket->nonblocking)) + return succeed(written); + if (!wait_changed(socket)) + return fail(WSAEWOULDBLOCK); + } +} + +/* puts a datagram in the queue of each socket of this machine bound to its +port */ +static void deliver_local_datagram(const struct address *from, const struct address *to, const void *buffer, + int length) +{ + int index, delivered = 0; + + for (index = 0; index < MAXIMUM_SOCKETS; index++) + { + struct web_socket *target = &sockets[index]; + struct datagram *datagram; + + if (!target->used || target->type != SOCK_DGRAM_VALUE || !target->bound || target->local.port != to->port) + continue; + if (target->datagram_count >= MAXIMUM_DATAGRAMS) + continue; + datagram = malloc(sizeof(*datagram) + (size_t)length); + if (!datagram) + continue; + datagram->next = NULL; + datagram->from = *from; + datagram->length = length; + memcpy(datagram->data, buffer, (size_t)length); + if (target->last) + target->last->next = datagram; + else + target->first = datagram; + target->last = datagram; + target->datagram_count++; + delivered = 1; + } + if (delivered) + pthread_cond_broadcast(&network_changed); +} + +static int deliver_datagram(struct web_socket *from, const void *buffer, int length, const struct address *to) +{ + struct address source = source_address(from, to->ip); + + if (length > MAXIMUM_DATAGRAM) + return fail(WSAEMSGSIZE); + TRACE("datagram %d bytes from %08x:%u to %08x:%u\n", length, source.ip, swap16(source.port), to->ip, + swap16(to->port)); + /* this machine's sockets, and through the page the other machines': + a broadcast goes to both */ + if (is_local(to->ip)) + deliver_local_datagram(&source, to, buffer, length); + if (!is_local(to->ip) || (is_broadcast(to->ip) && !is_loopback(to->ip))) + link_send(WEB_PACKET_DATAGRAM, &source, to, buffer, (unsigned int)length); + return succeed(length); +} + +int posix_socket_send(int descriptor, const void *buffer, int length, int flags) +{ + struct web_socket *socket; + int result = -1; + + (void)flags; + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (socket->type == SOCK_STREAM_VALUE) + result = stream_send(socket, buffer, length); + else if (!socket->connected) + result = fail(WSAENOTCONN); + else + result = deliver_datagram(socket, buffer, length, &socket->remote); + } + pthread_mutex_unlock(&network_lock); + return result; +} + +int posix_socket_sendto(int descriptor, const void *buffer, int length, int flags, + const void *address, int address_length) +{ + struct web_socket *socket; + struct address to; + int result = -1; + + if (!address || address_length < (int)sizeof(struct address) - 8) + return posix_socket_send(descriptor, buffer, length, flags); + memcpy(&to, address, sizeof(to) - 8); + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (socket->type == SOCK_STREAM_VALUE) + result = stream_send(socket, buffer, length); + else + { + bind_implicitly(socket); + result = deliver_datagram(socket, buffer, length, &to); + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +static int receive(int descriptor, void *buffer, int length, int flags, void *address, int *address_length) +{ + struct web_socket *socket; + int peek = (flags & 2) != 0; /* MSG_PEEK */ + int result = -1; + + pthread_mutex_lock(&network_lock); + link_pump(); + for (;;) + { + socket = socket_get(descriptor); + if (!socket) + break; + if (socket->type == SOCK_DGRAM_VALUE) + { + if (socket->first) + { + struct datagram *datagram = socket->first; + int datagram_length = datagram->length; + int count = datagram_length < length ? datagram_length : length; + + memcpy(buffer, datagram->data, (size_t)count); + if (address && address_length && *address_length >= (int)sizeof(struct address)) + { + memcpy(address, &datagram->from, sizeof(struct address)); + *address_length = (int)sizeof(struct address); + } + if (!peek) + { + socket->first = datagram->next; + if (!socket->first) + socket->last = NULL; + socket->datagram_count--; + free(datagram); + } + result = count < datagram_length && !peek ? fail(WSAEMSGSIZE) : succeed(count); + break; + } + } + else + { + if (!socket->connected) + { + result = fail(WSAENOTCONN); + break; + } + if (socket->stream_count) + { + int count = socket->stream_count < length ? socket->stream_count : length; + int index; + + for (index = 0; index < count; index++) + ((unsigned char *)buffer)[index] = socket->stream[(socket->stream_head + index) % STREAM_CAPACITY]; + if (!peek) + { + socket->stream_head = (socket->stream_head + count) % STREAM_CAPACITY; + socket->stream_count -= count; + pthread_cond_broadcast(&network_changed); + } + if (address && address_length && *address_length >= (int)sizeof(struct address)) + { + memcpy(address, &socket->remote, sizeof(struct address)); + *address_length = (int)sizeof(struct address); + } + TRACE("recv %d bytes on %d\n", count, descriptor); + result = succeed(count); + break; + } + if (socket->peer_closed) + { + /* the other end closed: end of stream */ + result = succeed(0); + break; + } + } + if (!wait_changed(socket)) + { + result = fail(WSAEWOULDBLOCK); + break; + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +int posix_socket_recv(int descriptor, void *buffer, int length, int flags) +{ + return receive(descriptor, buffer, length, flags, NULL, NULL); +} + +int posix_socket_recvfrom(int descriptor, void *buffer, int length, int flags, + void *address, int *address_length) +{ + return receive(descriptor, buffer, length, flags, address, address_length); +} + +int posix_socket_shutdown(int descriptor, int how) +{ + struct web_socket *socket; + + TRACE("shutdown %d %d\n", descriptor, how); + (void)how; + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + socket->shut_down = 1; + if (socket->peer >= 0) + sockets[socket->peer].peer_closed = 1; + if (socket->remote_link && !socket->peer_closed) + link_send(WEB_PACKET_CLOSE, &socket->local, &socket->remote, NULL, 0); + pthread_cond_broadcast(&network_changed); + } + pthread_mutex_unlock(&network_lock); + return socket ? succeed(0) : -1; +} + +int posix_socket_set_nonblocking(int descriptor, int nonblocking) +{ + struct web_socket *socket; + + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + socket->nonblocking = nonblocking != 0; + pthread_mutex_unlock(&network_lock); + return socket ? succeed(0) : -1; +} + +int posix_socket_set_nodelay(int descriptor) +{ + (void)descriptor; + return succeed(0); +} + +int posix_socket_bytes_available(int descriptor, posix_ulong *count) +{ + struct web_socket *socket; + + pthread_mutex_lock(&network_lock); + link_pump(); + socket = socket_get(descriptor); + if (socket) + *count = (posix_ulong)(socket->type == SOCK_DGRAM_VALUE ? + (socket->first ? socket->first->length : 0) : socket->stream_count); + pthread_mutex_unlock(&network_lock); + return socket ? succeed(0) : -1; +} + +int posix_socket_setsockopt(int descriptor, int level, int name, const void *value, int length) +{ + (void)level; + (void)name; + (void)value; + (void)length; + pthread_mutex_lock(&network_lock); + if (!socket_get(descriptor)) + { + pthread_mutex_unlock(&network_lock); + return -1; + } + pthread_mutex_unlock(&network_lock); + return succeed(0); +} + +int posix_socket_getsockopt(int descriptor, int level, int name, void *value, int *length) +{ + struct web_socket *socket; + + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket && value && length && *length >= 4) + { + int result = 0; + + if (level == 0xffff && name == 0x1008) /* SO_TYPE */ + result = socket->type; + if (level == 0xffff && (name == 0x1001 || name == 0x1002)) /* buffer sizes */ + result = STREAM_CAPACITY; + memcpy(value, &result, 4); + *length = 4; + } + pthread_mutex_unlock(&network_lock); + return socket ? succeed(0) : -1; +} + +int posix_socket_getsockname(int descriptor, void *address, int *address_length) +{ + struct web_socket *socket; + int result = -1; + + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (!address || !address_length || *address_length < (int)sizeof(struct address)) + result = fail(WSAEFAULT); + else + { + struct address local = socket->local; + + if (!socket->bound) + { + memset(&local, 0, sizeof(local)); + local.family = AF_INET_VALUE; + } + memcpy(address, &local, sizeof(local)); + *address_length = (int)sizeof(local); + result = succeed(0); + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +int posix_socket_getpeername(int descriptor, void *address, int *address_length) +{ + struct web_socket *socket; + int result = -1; + + pthread_mutex_lock(&network_lock); + socket = socket_get(descriptor); + if (socket) + { + if (!socket->connected) + result = fail(WSAENOTCONN); + else if (!address || !address_length || *address_length < (int)sizeof(struct address)) + result = fail(WSAEFAULT); + else + { + memcpy(address, &socket->remote, sizeof(struct address)); + *address_length = (int)sizeof(struct address); + result = succeed(0); + } + } + pthread_mutex_unlock(&network_lock); + return result; +} + +/* with the lock held */ +static int readable(int descriptor) +{ + struct web_socket *socket = socket_get(descriptor); + + if (!socket) + return 0; + if (socket->listening) + return socket->backlog_count > 0; + if (socket->type == SOCK_DGRAM_VALUE) + return socket->first != NULL; + return socket->stream_count > 0 || socket->peer_closed; +} + +static int writeable(int descriptor) +{ + struct web_socket *socket = socket_get(descriptor); + + if (!socket) + return 0; + if (socket->type == SOCK_DGRAM_VALUE) + return 1; + if (socket->remote_link) + return socket->connected && !socket->peer_closed; + return socket->connected && !socket->peer_closed && socket->peer >= 0 && + sockets[socket->peer].stream_count < STREAM_CAPACITY; +} + +static int keep(int *descriptors, int *count, int (*ready)(int)) +{ + int index, kept = 0; + + if (!descriptors || !count) + return 0; + for (index = 0; index < *count; index++) + { + if (ready(descriptors[index])) + descriptors[kept++] = descriptors[index]; + } + *count = kept; + return kept; +} + +static int any_ready(const int *descriptors, int count, int (*ready)(int)) +{ + int index; + + for (index = 0; descriptors && index < count; index++) + { + if (ready(descriptors[index])) + return 1; + } + return 0; +} + +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) +{ + struct timespec deadline; + int result; + + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += timeout_seconds + timeout_microseconds / 1000000; + deadline.tv_nsec += (timeout_microseconds % 1000000) * 1000L; + if (deadline.tv_nsec >= 1000000000L) + { + deadline.tv_sec++; + deadline.tv_nsec -= 1000000000L; + } + pthread_mutex_lock(&network_lock); + link_pump(); + for (;;) + { + struct timespec now; + + if (any_ready(read, read ? *read_count : 0, readable) || + any_ready(write, write ? *write_count : 0, writeable)) + break; + if (!infinite && timeout_seconds <= 0 && timeout_microseconds <= 0) + break; + clock_gettime(CLOCK_REALTIME, &now); + if (!infinite && (now.tv_sec > deadline.tv_sec || + (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec))) + break; + /* (packets from other machines arrive without a signal: look often) */ + wait_briefly(); + } + result = keep(read, read_count, readable) + keep(write, write_count, writeable); + if (error && error_count) + *error_count = 0; + pthread_mutex_unlock(&network_lock); + /* like Winsock, a select with nothing ready leaves the last error as it + was (a connect under way stays WSAEWOULDBLOCK) */ + if (result > 0) + last_error = 0; + return result; +} + +posix_ulong posix_local_ipv4_address(void) +{ + return (posix_ulong)WEB_LOCAL_ADDRESS; +} + +posix_ulong posix_resolve_ipv4(const char *host) +{ + unsigned int parts[4]; + int index = 0; + const char *cursor = host; + + if (!host) + return 0; + if (!strcmp(host, "localhost")) + return (posix_ulong)LOOPBACK_ADDRESS; + for (index = 0; index < 4; index++) + { + char *end; + unsigned long value = strtoul(cursor, &end, 10); + + if (end == cursor || value > 255 || (index < 3 && *end != '.') || (index == 3 && *end)) + return 0; + parts[index] = (unsigned int)value; + cursor = end + 1; + } + return (posix_ulong)(parts[0] | parts[1] << 8 | parts[2] << 16 | parts[3] << 24); +} diff --git a/port/web/src/web_ping.h b/port/web/src/web_ping.h new file mode 100644 index 0000000000..c70e272b64 --- /dev/null +++ b/port/web/src/web_ping.h @@ -0,0 +1,7 @@ +#ifndef HALO_WEB_PING_H +#define HALO_WEB_PING_H + +/* Native transport IPv4 order; returns RTT in ms, or -1 when unavailable. */ +int web_net_host_ping(unsigned int address, unsigned int host, unsigned int epoch); + +#endif diff --git a/port/web/src/web_pre.js b/port/web/src/web_pre.js new file mode 100644 index 0000000000..c007b17e66 --- /dev/null +++ b/port/web/src/web_pre.js @@ -0,0 +1,10 @@ +// WEB_PRE.JS: runs first in the page and in every thread (tools/web_build.py +// --pre-js). An error on one of the game's threads reaches the page with +// its stack (function names from the name section), for the log. +if (typeof WorkerGlobalScope != 'undefined' && self instanceof WorkerGlobalScope) { + self.addEventListener('error', (event) => { + var error = event.error; + var text = (error && error.stack) ? String(error.stack) : String(event.message); + postMessage({ cmd: 9, handler: 'haloMessage', args: [4, text] }); + }); +} diff --git a/port/web/src/web_quick_play.c b/port/web/src/web_quick_play.c new file mode 100644 index 0000000000..6d3c7e25a7 --- /dev/null +++ b/port/web/src/web_quick_play.c @@ -0,0 +1,145 @@ +/* Thread-safe quick-play control; the engine consumes requests on its own thread. */ +#if defined(HALO_QUICK_PLAY_TEST) || defined(HALO_IOS_BROWSER) +#define EMSCRIPTEN_KEEPALIVE +double emscripten_get_now(void); +#else +#include +#endif +#include +#include +#include + +void web_js_post(int kind, const char *text); +int web_multiplayer_active(void); +static int cancel_requested; +static uint64_t migration_requested; +static uint64_t reconnect_requested; +static unsigned int local_address; +static unsigned int initial_epoch; +static int hold_requested; +static int background_active; +/* Give deferred network/menu teardown a few frames after a terminal event. */ +static double background_drain_until; + +EMSCRIPTEN_KEEPALIVE void web_quick_play_cancel(void) +{ + __atomic_store_n(&migration_requested, 0, __ATOMIC_RELEASE); + __atomic_store_n(&reconnect_requested, 0, __ATOMIC_RELEASE); + __atomic_store_n(&hold_requested, 0, __ATOMIC_RELEASE); + __atomic_store_n(&cancel_requested, 1, __ATOMIC_RELEASE); +} + +/* The page elects; only the game thread adopts or reconnects a live session. + Role, epoch and target are one atomic request, never torn across elections. */ +EMSCRIPTEN_KEEPALIVE void web_quick_play_migrate(int mode, unsigned int target, unsigned int epoch) +{ + if ((mode != 1 && mode != 2) || !epoch || epoch > 0x7fffffffU) + return; + __atomic_store_n(&migration_requested, + ((uint64_t)((epoch << 1) | (mode == 1)) << 32) | target, __ATOMIC_RELEASE); +} + +/* Repair one client's transport within the current authority, including epoch 0. */ +EMSCRIPTEN_KEEPALIVE void web_quick_play_reconnect(unsigned int target, unsigned int epoch) +{ + if (!target || epoch > 0x7fffffffU) return; + __atomic_store_n(&reconnect_requested, + ((uint64_t)(epoch + 1U) << 32) | target, __ATOMIC_RELEASE); +} + +static unsigned int quick_play_engine_address(unsigned int address) +{ + return ((address & 0xffU) << 24) | ((address & 0xff00U) << 8) | + ((address >> 8) & 0xff00U) | ((address >> 24) & 0xffU); +} + +EMSCRIPTEN_KEEPALIVE void web_quick_play_set_address(unsigned int address) +{ + __atomic_store_n(&local_address, quick_play_engine_address(address), __ATOMIC_RELEASE); +} + +unsigned long web_quick_play_address(void) +{ + return __atomic_load_n(&local_address, __ATOMIC_ACQUIRE); +} + +EMSCRIPTEN_KEEPALIVE void web_quick_play_set_epoch(unsigned int epoch) +{ + if (epoch <= 0x7fffffffU) __atomic_store_n(&initial_epoch, epoch, __ATOMIC_RELEASE); +} + +unsigned long web_quick_play_initial_epoch(void) +{ + return __atomic_load_n(&initial_epoch, __ATOMIC_ACQUIRE); +} + +/* 1 freezes the existing world; 2 releases a brief connection-loss hold. */ +EMSCRIPTEN_KEEPALIVE void web_quick_play_hold(int hold) +{ + __atomic_store_n(&hold_requested, hold ? 1 : 2, __ATOMIC_RELEASE); +} + +int web_quick_play_take_hold(void) +{ + return __atomic_exchange_n(&hold_requested, 0, __ATOMIC_ACQ_REL); +} + +int web_quick_play_take_migrate(int *host, unsigned long *target, unsigned int *epoch) +{ + uint64_t request = __atomic_exchange_n(&migration_requested, 0, __ATOMIC_ACQ_REL); + unsigned int address = (unsigned int)request; + unsigned int control = (unsigned int)(request >> 32); + if (!request) + return 0; + *host = control & 1U; + *target = quick_play_engine_address(address); + *epoch = control >> 1; + return 1; +} + +int web_quick_play_take_cancel(void) +{ + return __atomic_exchange_n(&cancel_requested, 0, __ATOMIC_ACQ_REL); +} + +int web_quick_play_take_reconnect(unsigned long *target, unsigned int *epoch) +{ + uint64_t request = __atomic_exchange_n(&reconnect_requested, 0, __ATOMIC_ACQ_REL); + if (!request) return 0; + *target = quick_play_engine_address((unsigned int)request); + *epoch = (unsigned int)(request >> 32) - 1U; + return 1; +} + +int web_quick_play_background_active(void) +{ + /* A cancel must also wake a game that was waiting for a hidden page. */ + return web_multiplayer_active() || + __atomic_load_n(&background_active, __ATOMIC_ACQUIRE) || + __atomic_load_n(&cancel_requested, __ATOMIC_ACQUIRE) || + __atomic_load_n(&migration_requested, __ATOMIC_ACQUIRE) || + __atomic_load_n(&reconnect_requested, __ATOMIC_ACQUIRE) || + __atomic_load_n(&hold_requested, __ATOMIC_ACQUIRE) || + emscripten_get_now() < background_drain_until; +} + +/* Reports an atomically received checkpoint; selection never elects a tab + which merely rendered the map but has no recoverable authority state. */ +void web_quick_play_checkpoint(unsigned int epoch, long tick, unsigned long seed) +{ + char json[160]; + snprintf(json, sizeof(json), "{\"phase\":\"checkpoint\",\"epoch\":%u,\"tick\":%ld,\"matchId\":%lu}", epoch, tick, seed); + web_js_post(6, json); +} + +void web_quick_play_report(const char *phase, const char *message) +{ + char json[512]; + int active = strcmp(phase, "menu") && strcmp(phase, "error"); + if (!active) + background_drain_until = emscripten_get_now() + 1000.0; + __atomic_store_n(&background_active, active, __ATOMIC_RELEASE); + /* Both strings are fixed engine literals, with no user text or quotes. */ + snprintf(json, sizeof(json), "{\"phase\":\"%s\",\"message\":\"%s\"}", phase, message); + web_js_post(6, json); +} diff --git a/port/web/src/web_scancodes.c b/port/web/src/web_scancodes.c new file mode 100644 index 0000000000..88ea08dce9 --- /dev/null +++ b/port/web/src/web_scancodes.c @@ -0,0 +1,335 @@ +/* + Simple DirectMedia Layer + Copyright (C) 1997-2026 Sam Lantinga + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. +*/ +/* Adapted from SDL 3.4.16 src/events/SDL_keymap.c. The browser shim does + not link SDL; retain its key names for upstream's configurable controls. + The table is unchanged; lookup uses libc and does not set SDL errors. */ +#include +#include + +static const char *SDL_scancode_names[SDL_SCANCODE_COUNT] = +{ + /* 0 */ NULL, + /* 1 */ NULL, + /* 2 */ NULL, + /* 3 */ NULL, + /* 4 */ "A", + /* 5 */ "B", + /* 6 */ "C", + /* 7 */ "D", + /* 8 */ "E", + /* 9 */ "F", + /* 10 */ "G", + /* 11 */ "H", + /* 12 */ "I", + /* 13 */ "J", + /* 14 */ "K", + /* 15 */ "L", + /* 16 */ "M", + /* 17 */ "N", + /* 18 */ "O", + /* 19 */ "P", + /* 20 */ "Q", + /* 21 */ "R", + /* 22 */ "S", + /* 23 */ "T", + /* 24 */ "U", + /* 25 */ "V", + /* 26 */ "W", + /* 27 */ "X", + /* 28 */ "Y", + /* 29 */ "Z", + /* 30 */ "1", + /* 31 */ "2", + /* 32 */ "3", + /* 33 */ "4", + /* 34 */ "5", + /* 35 */ "6", + /* 36 */ "7", + /* 37 */ "8", + /* 38 */ "9", + /* 39 */ "0", + /* 40 */ "Return", + /* 41 */ "Escape", + /* 42 */ "Backspace", + /* 43 */ "Tab", + /* 44 */ "Space", + /* 45 */ "-", + /* 46 */ "=", + /* 47 */ "[", + /* 48 */ "]", + /* 49 */ "\\", + /* 50 */ "#", + /* 51 */ ";", + /* 52 */ "'", + /* 53 */ "`", + /* 54 */ ",", + /* 55 */ ".", + /* 56 */ "/", + /* 57 */ "CapsLock", + /* 58 */ "F1", + /* 59 */ "F2", + /* 60 */ "F3", + /* 61 */ "F4", + /* 62 */ "F5", + /* 63 */ "F6", + /* 64 */ "F7", + /* 65 */ "F8", + /* 66 */ "F9", + /* 67 */ "F10", + /* 68 */ "F11", + /* 69 */ "F12", + /* 70 */ "PrintScreen", + /* 71 */ "ScrollLock", + /* 72 */ "Pause", + /* 73 */ "Insert", + /* 74 */ "Home", + /* 75 */ "PageUp", + /* 76 */ "Delete", + /* 77 */ "End", + /* 78 */ "PageDown", + /* 79 */ "Right", + /* 80 */ "Left", + /* 81 */ "Down", + /* 82 */ "Up", + /* 83 */ "Numlock", + /* 84 */ "Keypad /", + /* 85 */ "Keypad *", + /* 86 */ "Keypad -", + /* 87 */ "Keypad +", + /* 88 */ "Keypad Enter", + /* 89 */ "Keypad 1", + /* 90 */ "Keypad 2", + /* 91 */ "Keypad 3", + /* 92 */ "Keypad 4", + /* 93 */ "Keypad 5", + /* 94 */ "Keypad 6", + /* 95 */ "Keypad 7", + /* 96 */ "Keypad 8", + /* 97 */ "Keypad 9", + /* 98 */ "Keypad 0", + /* 99 */ "Keypad .", + /* 100 */ "NonUSBackslash", + /* 101 */ "Application", + /* 102 */ "Power", + /* 103 */ "Keypad =", + /* 104 */ "F13", + /* 105 */ "F14", + /* 106 */ "F15", + /* 107 */ "F16", + /* 108 */ "F17", + /* 109 */ "F18", + /* 110 */ "F19", + /* 111 */ "F20", + /* 112 */ "F21", + /* 113 */ "F22", + /* 114 */ "F23", + /* 115 */ "F24", + /* 116 */ "Execute", + /* 117 */ "Help", + /* 118 */ "Menu", + /* 119 */ "Select", + /* 120 */ "Stop", + /* 121 */ "Again", + /* 122 */ "Undo", + /* 123 */ "Cut", + /* 124 */ "Copy", + /* 125 */ "Paste", + /* 126 */ "Find", + /* 127 */ "Mute", + /* 128 */ "VolumeUp", + /* 129 */ "VolumeDown", + /* 130 */ NULL, + /* 131 */ NULL, + /* 132 */ NULL, + /* 133 */ "Keypad ,", + /* 134 */ "Keypad = (AS400)", + /* 135 */ "International 1", + /* 136 */ "International 2", + /* 137 */ "International 3", + /* 138 */ "International 4", + /* 139 */ "International 5", + /* 140 */ "International 6", + /* 141 */ "International 7", + /* 142 */ "International 8", + /* 143 */ "International 9", + /* 144 */ "Language 1", + /* 145 */ "Language 2", + /* 146 */ "Language 3", + /* 147 */ "Language 4", + /* 148 */ "Language 5", + /* 149 */ "Language 6", + /* 150 */ "Language 7", + /* 151 */ "Language 8", + /* 152 */ "Language 9", + /* 153 */ "AltErase", + /* 154 */ "SysReq", + /* 155 */ "Cancel", + /* 156 */ "Clear", + /* 157 */ "Prior", + /* 158 */ "Return", + /* 159 */ "Separator", + /* 160 */ "Out", + /* 161 */ "Oper", + /* 162 */ "Clear / Again", + /* 163 */ "CrSel", + /* 164 */ "ExSel", + /* 165 */ NULL, + /* 166 */ NULL, + /* 167 */ NULL, + /* 168 */ NULL, + /* 169 */ NULL, + /* 170 */ NULL, + /* 171 */ NULL, + /* 172 */ NULL, + /* 173 */ NULL, + /* 174 */ NULL, + /* 175 */ NULL, + /* 176 */ "Keypad 00", + /* 177 */ "Keypad 000", + /* 178 */ "ThousandsSeparator", + /* 179 */ "DecimalSeparator", + /* 180 */ "CurrencyUnit", + /* 181 */ "CurrencySubUnit", + /* 182 */ "Keypad (", + /* 183 */ "Keypad )", + /* 184 */ "Keypad {", + /* 185 */ "Keypad }", + /* 186 */ "Keypad Tab", + /* 187 */ "Keypad Backspace", + /* 188 */ "Keypad A", + /* 189 */ "Keypad B", + /* 190 */ "Keypad C", + /* 191 */ "Keypad D", + /* 192 */ "Keypad E", + /* 193 */ "Keypad F", + /* 194 */ "Keypad XOR", + /* 195 */ "Keypad ^", + /* 196 */ "Keypad %", + /* 197 */ "Keypad <", + /* 198 */ "Keypad >", + /* 199 */ "Keypad &", + /* 200 */ "Keypad &&", + /* 201 */ "Keypad |", + /* 202 */ "Keypad ||", + /* 203 */ "Keypad :", + /* 204 */ "Keypad #", + /* 205 */ "Keypad Space", + /* 206 */ "Keypad @", + /* 207 */ "Keypad !", + /* 208 */ "Keypad MemStore", + /* 209 */ "Keypad MemRecall", + /* 210 */ "Keypad MemClear", + /* 211 */ "Keypad MemAdd", + /* 212 */ "Keypad MemSubtract", + /* 213 */ "Keypad MemMultiply", + /* 214 */ "Keypad MemDivide", + /* 215 */ "Keypad +/-", + /* 216 */ "Keypad Clear", + /* 217 */ "Keypad ClearEntry", + /* 218 */ "Keypad Binary", + /* 219 */ "Keypad Octal", + /* 220 */ "Keypad Decimal", + /* 221 */ "Keypad Hexadecimal", + /* 222 */ NULL, + /* 223 */ NULL, + /* 224 */ "Left Ctrl", + /* 225 */ "Left Shift", + /* 226 */ "Left Alt", + /* 227 */ "Left GUI", + /* 228 */ "Right Ctrl", + /* 229 */ "Right Shift", + /* 230 */ "Right Alt", + /* 231 */ "Right GUI", + /* 232 */ NULL, + /* 233 */ NULL, + /* 234 */ NULL, + /* 235 */ NULL, + /* 236 */ NULL, + /* 237 */ NULL, + /* 238 */ NULL, + /* 239 */ NULL, + /* 240 */ NULL, + /* 241 */ NULL, + /* 242 */ NULL, + /* 243 */ NULL, + /* 244 */ NULL, + /* 245 */ NULL, + /* 246 */ NULL, + /* 247 */ NULL, + /* 248 */ NULL, + /* 249 */ NULL, + /* 250 */ NULL, + /* 251 */ NULL, + /* 252 */ NULL, + /* 253 */ NULL, + /* 254 */ NULL, + /* 255 */ NULL, + /* 256 */ NULL, + /* 257 */ "ModeSwitch", + /* 258 */ "Sleep", + /* 259 */ "Wake", + /* 260 */ "ChannelUp", + /* 261 */ "ChannelDown", + /* 262 */ "MediaPlay", + /* 263 */ "MediaPause", + /* 264 */ "MediaRecord", + /* 265 */ "MediaFastForward", + /* 266 */ "MediaRewind", + /* 267 */ "MediaTrackNext", + /* 268 */ "MediaTrackPrevious", + /* 269 */ "MediaStop", + /* 270 */ "Eject", + /* 271 */ "MediaPlayPause", + /* 272 */ "MediaSelect", + /* 273 */ "AC New", + /* 274 */ "AC Open", + /* 275 */ "AC Close", + /* 276 */ "AC Exit", + /* 277 */ "AC Save", + /* 278 */ "AC Print", + /* 279 */ "AC Properties", + /* 280 */ "AC Search", + /* 281 */ "AC Home", + /* 282 */ "AC Back", + /* 283 */ "AC Forward", + /* 284 */ "AC Stop", + /* 285 */ "AC Refresh", + /* 286 */ "AC Bookmarks", + /* 287 */ "SoftLeft", + /* 288 */ "SoftRight", + /* 289 */ "Call", + /* 290 */ "EndCall", +}; + +const char *SDL_GetScancodeName(SDL_Scancode code) +{ + return (int)code >= 0 && code < SDL_SCANCODE_COUNT && SDL_scancode_names[code] + ? SDL_scancode_names[code] : ""; +} + +SDL_Scancode SDL_GetScancodeFromName(const char *name) +{ + if (!name || !*name) return SDL_SCANCODE_UNKNOWN; + for (int i = 0; i < SDL_SCANCODE_COUNT; i++) + if (SDL_scancode_names[i] && !strcasecmp(name, SDL_scancode_names[i])) + return (SDL_Scancode)i; + return SDL_SCANCODE_UNKNOWN; +} diff --git a/port/web/src/web_sdl.c b/port/web/src/web_sdl.c new file mode 100644 index 0000000000..dfd89c4eaa --- /dev/null +++ b/port/web/src/web_sdl.c @@ -0,0 +1,689 @@ +/* +WEB_SDL.C + +The SDL3 functions the platform layer calls (sdl_platform.c, xinput_sdl.c, +dsound_sdl.c, gl_functions.c), over the browser. The list is the Android +guest's (port/android/guest/runtime/guest_sdl.c). + +The game runs on a pthread, in a Web Worker, so it may block like a native +program. What only the page's main thread can reach (the DOM's events, the +Gamepad API, Web Audio) arrives through the shared state (web_shared.h), +which port/web/site/app.js fills. The OpenGL ES 3 context is WebGL 2 on an +OffscreenCanvas that belongs to the game's thread (web_library.js); each +frame goes to the page as an ImageBitmap. +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "web_shared.h" + +/* web_library.js */ +int web_js_gl_create(int width, int height, int batch_streams, int present_ack, int pixel_frames); +void web_js_gl_resize(int width, int height); +void web_js_gl_flush(void); +void web_js_gl_present(void); +void web_js_post(int kind, const char *text); +int web_quick_play_background_active(void); + +/* ---------- the shared state */ + +static struct web_shared_state shared_state = { WEB_SHARED_MAGIC, WEB_SHARED_VERSION, sizeof(struct web_shared_state) }; + +EMSCRIPTEN_KEEPALIVE struct web_shared_state *web_shared_state(void) +{ + return &shared_state; +} + +/* the byte offsets of the members the page uses, so that app.js need not +repeat the structure's layout */ +enum +{ + WEB_OFFSET_SIZE, + WEB_OFFSET_EVENT_WRITE, + WEB_OFFSET_EVENT_READ, + WEB_OFFSET_EVENTS, + WEB_OFFSET_EVENT_SIZE, + WEB_OFFSET_GAMEPADS, + WEB_OFFSET_GAMEPAD_SIZE, + WEB_OFFSET_DISPLAY_WIDTH, + WEB_OFFSET_DISPLAY_HEIGHT, + WEB_OFFSET_FRAME_COUNTER, + WEB_OFFSET_FRAMES_PRESENTED, + WEB_OFFSET_VSYNC, + WEB_OFFSET_AUDIO_RATE, + WEB_OFFSET_AUDIO_OPEN, + WEB_OFFSET_AUDIO_WRITE, + WEB_OFFSET_AUDIO_READ, + WEB_OFFSET_AUDIO_UNDERRUNS, + WEB_OFFSET_AUDIO_RING, + WEB_OFFSET_AUDIO_RING_FRAMES, + WEB_OFFSET_PAGE_HIDDEN, + WEB_OFFSET_GAME_STARTED, + WEB_OFFSET_EVENT_CAPACITY, + WEB_OFFSET_GAMEPAD_COUNT, + WEB_OFFSET_NET_LOCAL_ADDRESS, + WEB_OFFSET_NET_OUT_WRITE, + WEB_OFFSET_NET_OUT_READ, + WEB_OFFSET_NET_IN_WRITE, + WEB_OFFSET_NET_IN_READ, + WEB_OFFSET_NET_OUT, + WEB_OFFSET_NET_OUT_BYTES, + WEB_OFFSET_NET_IN, + WEB_OFFSET_NET_IN_BYTES, + WEB_OFFSET_GATEWAY_ENABLED, + WEB_OFFSET_GATEWAY_IDENTIFIER, + WEB_OFFSET_GATEWAY_PEERS, + WEB_OFFSET_GATEWAY_PEER_COUNT, + WEB_OFFSET_PING_SEQUENCE, + WEB_OFFSET_PING_HOST, + WEB_OFFSET_PING_EPOCH, + WEB_OFFSET_PING_UPDATED, + WEB_OFFSET_PING_COUNT, + WEB_OFFSET_PING_PEERS, + WEB_OFFSET_PING_PEER_COUNT, + NUMBER_OF_WEB_OFFSETS +}; + +EMSCRIPTEN_KEEPALIVE const int *web_shared_offsets(void) +{ + static int offsets[NUMBER_OF_WEB_OFFSETS]; + +#define OFFSET(member) ((int)((char *)&shared_state.member - (char *)&shared_state)) + offsets[WEB_OFFSET_SIZE] = (int)sizeof(shared_state); + offsets[WEB_OFFSET_EVENT_WRITE] = OFFSET(event_write); + offsets[WEB_OFFSET_EVENT_READ] = OFFSET(event_read); + offsets[WEB_OFFSET_EVENTS] = OFFSET(events); + offsets[WEB_OFFSET_EVENT_SIZE] = (int)sizeof(struct web_event); + offsets[WEB_OFFSET_GAMEPADS] = OFFSET(gamepads); + offsets[WEB_OFFSET_GAMEPAD_SIZE] = (int)sizeof(struct web_gamepad); + offsets[WEB_OFFSET_DISPLAY_WIDTH] = OFFSET(display_width); + offsets[WEB_OFFSET_DISPLAY_HEIGHT] = OFFSET(display_height); + offsets[WEB_OFFSET_FRAME_COUNTER] = OFFSET(frame_counter); + offsets[WEB_OFFSET_FRAMES_PRESENTED] = OFFSET(frames_presented); + offsets[WEB_OFFSET_VSYNC] = OFFSET(vsync); + offsets[WEB_OFFSET_AUDIO_RATE] = OFFSET(audio_rate); + offsets[WEB_OFFSET_AUDIO_OPEN] = OFFSET(audio_open); + offsets[WEB_OFFSET_AUDIO_WRITE] = OFFSET(audio_write); + offsets[WEB_OFFSET_AUDIO_READ] = OFFSET(audio_read); + offsets[WEB_OFFSET_AUDIO_UNDERRUNS] = OFFSET(audio_underruns); + offsets[WEB_OFFSET_AUDIO_RING] = OFFSET(audio_ring); + offsets[WEB_OFFSET_AUDIO_RING_FRAMES] = WEB_AUDIO_RING_FRAMES; + offsets[WEB_OFFSET_PAGE_HIDDEN] = OFFSET(page_hidden); + offsets[WEB_OFFSET_GAME_STARTED] = OFFSET(game_started); + offsets[WEB_OFFSET_EVENT_CAPACITY] = WEB_EVENT_CAPACITY; + offsets[WEB_OFFSET_GAMEPAD_COUNT] = WEB_GAMEPAD_COUNT; + offsets[WEB_OFFSET_NET_LOCAL_ADDRESS] = OFFSET(net_local_address); + offsets[WEB_OFFSET_NET_OUT_WRITE] = OFFSET(net_out_write); + offsets[WEB_OFFSET_NET_OUT_READ] = OFFSET(net_out_read); + offsets[WEB_OFFSET_NET_IN_WRITE] = OFFSET(net_in_write); + offsets[WEB_OFFSET_NET_IN_READ] = OFFSET(net_in_read); + offsets[WEB_OFFSET_NET_OUT] = OFFSET(net_out); + offsets[WEB_OFFSET_NET_OUT_BYTES] = WEB_NET_OUT_BYTES; + offsets[WEB_OFFSET_NET_IN] = OFFSET(net_in); + offsets[WEB_OFFSET_NET_IN_BYTES] = WEB_NET_IN_BYTES; + offsets[WEB_OFFSET_GATEWAY_ENABLED] = OFFSET(gateway_enabled); + offsets[WEB_OFFSET_GATEWAY_IDENTIFIER] = OFFSET(gateway_identifier); + offsets[WEB_OFFSET_GATEWAY_PEERS] = OFFSET(gateway_peers); + offsets[WEB_OFFSET_GATEWAY_PEER_COUNT] = WEB_GATEWAY_PEERS; + offsets[WEB_OFFSET_PING_SEQUENCE] = OFFSET(ping_sequence); + offsets[WEB_OFFSET_PING_HOST] = OFFSET(ping_host); + offsets[WEB_OFFSET_PING_EPOCH] = OFFSET(ping_epoch); + offsets[WEB_OFFSET_PING_UPDATED] = OFFSET(ping_updated); + offsets[WEB_OFFSET_PING_COUNT] = OFFSET(ping_count); + offsets[WEB_OFFSET_PING_PEERS] = OFFSET(ping_peers); + offsets[WEB_OFFSET_PING_PEER_COUNT] = WEB_PING_PEERS; +#undef OFFSET + return offsets; +} + +/* ---------- basics */ + +static char sdl_error[256]; + +static void set_error(const char *text) +{ + snprintf(sdl_error, sizeof(sdl_error), "%s", text); +} + +bool SDL_Init(SDL_InitFlags flags) +{ + (void)flags; + return true; +} + +bool SDL_SetHint(const char *name, const char *value) +{ + (void)name; + (void)value; + return true; +} + +const char *SDL_GetError(void) +{ + return sdl_error; +} + +Uint64 SDL_GetTicks(void) +{ + return (Uint64)emscripten_get_now(); +} + +SDL_ThreadID SDL_GetCurrentThreadID(void) +{ + return (SDL_ThreadID)(uintptr_t)pthread_self(); +} + +void SDL_free(void *memory) +{ + free(memory); +} + +void SDL_Delay(Uint32 milliseconds) +{ + usleep((useconds_t)milliseconds * 1000); +} + +/* ---------- the clipboard, messages */ + +static char clipboard[1024]; + +bool SDL_SetClipboardText(const char *text) +{ + snprintf(clipboard, sizeof(clipboard), "%s", text ? text : ""); + web_js_post(2, clipboard); + return true; +} + +char *SDL_GetClipboardText(void) +{ + return strdup(clipboard); +} + +bool SDL_ShowAndroidToast(const char *message, int duration, int gravity, int xoffset, int yoffset) +{ + (void)duration; + (void)gravity; + (void)xoffset; + (void)yoffset; + web_js_post(1, message); + return true; +} + +bool SDL_ShowSimpleMessageBox(SDL_MessageBoxFlags flags, const char *title, const char *message, SDL_Window *window) +{ + char text[1024]; + + (void)flags; + (void)window; + snprintf(text, sizeof(text), "%s: %s", title ? title : "Halo", message ? message : ""); + web_js_post(1, text); + return true; +} + +/* ---------- video */ + +#define WEB_WINDOW ((SDL_Window *)1) + +static int gl_context; +static int canvas_width, canvas_height; +static int swap_interval = 1; +static int last_frame_counter; + +static void display_size(int *width, int *height) +{ + int w = shared_state.display_width; + int h = shared_state.display_height; + + if (w < 64 || h < 64) + { + w = 1280; + h = 720; + } + *width = w; + *height = h; +} + +SDL_Window *SDL_CreateWindow(const char *title, int width, int height, SDL_WindowFlags flags) +{ + (void)title; + (void)width; + (void)height; + (void)flags; + return WEB_WINDOW; +} + +bool SDL_GetWindowSizeInPixels(SDL_Window *window, int *width, int *height) +{ + (void)window; + if (width) + *width = canvas_width; + if (height) + *height = canvas_height; + return true; +} + +bool SDL_SetWindowRelativeMouseMode(SDL_Window *window, bool enabled) +{ + (void)window; + (void)enabled; + /* the page locks the pointer when the player clicks the game */ + return true; +} + +bool SDL_GL_SetAttribute(SDL_GLAttr attribute, int value) +{ + (void)attribute; + (void)value; + return true; +} + +SDL_GLContext SDL_GL_CreateContext(SDL_Window *window) +{ + (void)window; + if (!gl_context) + { + const char *batch_streams = getenv("HALO_WEB_BATCH_STREAMS"); + const char *geometry_cache = getenv("HALO_WEB_GEOMETRY_CACHE"); + const char *present_ack = getenv("HALO_WEB_PRESENT_ACK"); + const char *pixel_frames = getenv("HALO_WEB_PIXEL_FRAMES"); + + display_size(&canvas_width, &canvas_height); + gl_context = web_js_gl_create(canvas_width, canvas_height, + (!batch_streams || strcmp(batch_streams, "0")) && + !(geometry_cache && !strcmp(geometry_cache, "1")), + present_ack && !strcmp(present_ack, "1"), + pixel_frames && !strcmp(pixel_frames, "1")); + if (!gl_context) + { + set_error("WebGL 2 is not available"); + return NULL; + } + shared_state.game_started = 1; + } + return (SDL_GLContext)(uintptr_t)gl_context; +} + +bool SDL_GL_MakeCurrent(SDL_Window *window, SDL_GLContext context) +{ + (void)window; + return context && emscripten_webgl_make_context_current((EMSCRIPTEN_WEBGL_CONTEXT_HANDLE)(uintptr_t)context) == + EMSCRIPTEN_RESULT_SUCCESS; +} + +bool SDL_GL_SetSwapInterval(int interval) +{ + swap_interval = interval; + shared_state.vsync = interval != 0; + return true; +} + +bool SDL_GL_SwapWindow(SDL_Window *window) +{ + int width, height; + static double last_swap_time; + + (void)window; + if (!gl_context) + return false; + /* Hidden multiplayer still simulates, but never queues ImageBitmaps for + a page whose rendering callbacks are suspended. */ + if (!shared_state.page_hidden) + { + web_js_gl_present(); + __atomic_add_fetch(&shared_state.frames_presented, 1, __ATOMIC_SEQ_CST); + } + else + web_js_gl_flush(); + + /* the next frame waits for the page's next animation frame, as a swap + interval of one waits for the display. Multiplayer keeps a bounded 30Hz + worker cadence while hidden; requestAnimationFrame stops in hidden tabs. */ + if (shared_state.page_hidden && web_quick_play_background_active()) + { + double now = emscripten_get_now(); + double deadline = last_swap_time + 1000.0 / 30.0; + while (shared_state.page_hidden && web_quick_play_background_active() && + now < deadline && deadline - now <= 1000.0 / 30.0) + { + int counter = __atomic_load_n(&shared_state.frame_counter, __ATOMIC_SEQ_CST); + emscripten_futex_wait((void *)&shared_state.frame_counter, (uint32_t)counter, deadline - now); + now = emscripten_get_now(); + } + } + else if (swap_interval || shared_state.page_hidden) + { + int counter = __atomic_load_n(&shared_state.frame_counter, __ATOMIC_SEQ_CST); + + while (counter == last_frame_counter) + { + emscripten_futex_wait((void *)&shared_state.frame_counter, (uint32_t)counter, + shared_state.page_hidden ? 250.0 : 100.0); + counter = __atomic_load_n(&shared_state.frame_counter, __ATOMIC_SEQ_CST); + if (!shared_state.page_hidden || web_quick_play_background_active()) + break; + } + last_frame_counter = counter; + } + last_swap_time = emscripten_get_now(); + + /* the page's canvas changed size (rotation, resizing): so does the + drawing buffer, between frames */ + display_size(&width, &height); + if (width != canvas_width || height != canvas_height) + { + canvas_width = width; + canvas_height = height; + web_js_gl_resize(width, height); + } + return true; +} + +/* OpenGL ES 3.2 functions WebGL 2 lacks: the renderer asks for them, but +calls them only when the context has them (d3d8_gl.c gl_initialize) */ +static void web_glCopyImageSubData(GLuint source, GLenum source_target, GLint source_level, GLint source_x, + GLint source_y, GLint source_z, GLuint destination, GLenum destination_target, GLint destination_level, + GLint destination_x, GLint destination_y, GLint destination_z, GLsizei width, GLsizei height, GLsizei depth) +{ +} + +static void web_glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const void *indices, + GLint base_vertex) +{ +} + +SDL_FunctionPointer SDL_GL_GetProcAddress(const char *name) +{ + void *function = emscripten_webgl_get_proc_address(name); + + if (!function && !strcmp(name, "glCopyImageSubData")) + function = (void *)web_glCopyImageSubData; + if (!function && !strcmp(name, "glDrawElementsBaseVertex")) + function = (void *)web_glDrawElementsBaseVertex; + return (SDL_FunctionPointer)function; +} + +/* ---------- events */ + +bool SDL_PollEvent(SDL_Event *event) +{ + int read = __atomic_load_n(&shared_state.event_read, __ATOMIC_SEQ_CST); + int write = __atomic_load_n(&shared_state.event_write, __ATOMIC_SEQ_CST); + struct web_event source; + + if (read == write) + return false; + source = shared_state.events[read % WEB_EVENT_CAPACITY]; + __atomic_store_n(&shared_state.event_read, read + 1, __ATOMIC_SEQ_CST); + if (!event) + return true; + memset(event, 0, sizeof(*event)); + event->common.timestamp = (Uint64)(emscripten_get_now() * 1000000.0); + switch (source.type) + { + case WEB_EVENT_KEY: + event->type = source.b ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + event->key.scancode = (SDL_Scancode)source.a; + event->key.down = source.b != 0; + event->key.repeat = source.c != 0; + event->key.key = (SDL_Keycode)source.d; + event->key.mod = (SDL_Keymod)source.reserved; + break; + case WEB_EVENT_MOUSE_MOTION: + event->type = SDL_EVENT_MOUSE_MOTION; + event->motion.xrel = source.x; + event->motion.yrel = source.y; + break; + case WEB_EVENT_MOUSE_BUTTON: + event->type = source.b ? SDL_EVENT_MOUSE_BUTTON_DOWN : SDL_EVENT_MOUSE_BUTTON_UP; + event->button.button = (Uint8)source.a; + event->button.down = source.b != 0; + event->button.clicks = 1; + break; + case WEB_EVENT_MOUSE_WHEEL: + event->type = SDL_EVENT_MOUSE_WHEEL; + event->wheel.y = source.y; + break; + case WEB_EVENT_FOCUS: + event->type = source.a ? SDL_EVENT_WINDOW_FOCUS_GAINED : SDL_EVENT_WINDOW_FOCUS_LOST; + break; + case WEB_EVENT_GAMEPAD_ADDED: + event->type = SDL_EVENT_GAMEPAD_ADDED; + event->gdevice.which = (SDL_JoystickID)source.a; + break; + case WEB_EVENT_QUIT: + /* the page never closes the game; a hidden page only pauses it */ + event->type = SDL_EVENT_USER; + break; + default: + event->type = SDL_EVENT_USER; + break; + } + return true; +} + +/* ---------- gamepads: a gamepad handle is its slot plus one */ + +static struct web_gamepad *gamepad_slot(SDL_Gamepad *gamepad) +{ + uintptr_t index = (uintptr_t)gamepad; + + if (index < 1 || index > WEB_GAMEPAD_COUNT) + return NULL; + return &shared_state.gamepads[index - 1]; +} + +SDL_JoystickID *SDL_GetGamepads(int *count) +{ + SDL_JoystickID *result = malloc((WEB_GAMEPAD_COUNT + 1) * sizeof(SDL_JoystickID)); + int index, found = 0; + + if (!result) + return NULL; + for (index = 0; index < WEB_GAMEPAD_COUNT; index++) + { + if (shared_state.gamepads[index].connected && shared_state.gamepads[index].id) + result[found++] = (SDL_JoystickID)shared_state.gamepads[index].id; + } + result[found] = 0; + if (count) + *count = found; + return result; +} + +SDL_Gamepad *SDL_GetGamepadFromID(SDL_JoystickID id) +{ + int index; + + for (index = 0; index < WEB_GAMEPAD_COUNT; index++) + { + if (shared_state.gamepads[index].connected && (SDL_JoystickID)shared_state.gamepads[index].id == id) + return (SDL_Gamepad *)(uintptr_t)(index + 1); + } + return NULL; +} + +SDL_Gamepad *SDL_OpenGamepad(SDL_JoystickID id) +{ + return SDL_GetGamepadFromID(id); +} + +Sint16 SDL_GetGamepadAxis(SDL_Gamepad *gamepad, SDL_GamepadAxis axis) +{ + struct web_gamepad *pad = gamepad_slot(gamepad); + int value; + + if (!pad || !pad->connected || axis < 0 || axis >= 6) + return 0; + value = pad->axes[axis]; + if (value < -32768) + value = -32768; + if (value > 32767) + value = 32767; + return (Sint16)value; +} + +bool SDL_GetGamepadButton(SDL_Gamepad *gamepad, SDL_GamepadButton button) +{ + struct web_gamepad *pad = gamepad_slot(gamepad); + + if (!pad || !pad->connected || button < 0 || button >= 32) + return false; + return (pad->buttons & (1u << button)) != 0; +} + +SDL_GamepadType SDL_GetGamepadType(SDL_Gamepad *gamepad) +{ + struct web_gamepad *pad = gamepad_slot(gamepad); + + return pad && pad->connected ? (SDL_GamepadType)pad->type : SDL_GAMEPAD_TYPE_UNKNOWN; +} + +bool SDL_RumbleGamepad(SDL_Gamepad *gamepad, Uint16 low, Uint16 high, Uint32 milliseconds) +{ + struct web_gamepad *pad = gamepad_slot(gamepad); + + if (!pad || !pad->connected) + return false; + if (pad->rumble_low == low && pad->rumble_high == high) + return true; + pad->rumble_low = low; + pad->rumble_high = high; + pad->rumble_milliseconds = (int32_t)milliseconds; + __atomic_add_fetch(&pad->rumble_serial, 1, __ATOMIC_SEQ_CST); + return true; +} + +/* ---------- audio + +A thread of its own keeps the ring about AUDIO_TARGET_FRAMES ahead of the +page's AudioWorklet, calling the mixer (dsound_sdl.c's callback) as SDL's +audio thread would. Until the page's audio runs (browsers start it only +after the player touches the page) and while the page is hidden, the ring is +drained in real time instead, so the game's sounds still finish on time. */ + +#define AUDIO_TARGET_FRAMES 3072 +#define AUDIO_CHANNELS 2 + +struct web_audio_stream +{ + SDL_AudioStreamCallback callback; + void *userdata; + int rate; + volatile int resumed; +}; + +static struct web_audio_stream audio_stream; +static pthread_t audio_thread; + +static void *audio_thread_main(void *parameter) +{ + double drained_at = emscripten_get_now(); + + (void)parameter; + for (;;) + { + int write = __atomic_load_n(&shared_state.audio_write, __ATOMIC_SEQ_CST); + int read = __atomic_load_n(&shared_state.audio_read, __ATOMIC_SEQ_CST); + int queued = write - read; + + if (!shared_state.audio_open || shared_state.page_hidden) + { + /* nobody plays the ring: consume it at the sample rate */ + double now = emscripten_get_now(); + int frames = (int)((now - drained_at) * audio_stream.rate / 1000.0); + + if (frames > 0) + { + drained_at += frames * 1000.0 / audio_stream.rate; + if (frames > queued) + frames = queued; + __atomic_add_fetch(&shared_state.audio_read, frames, __ATOMIC_SEQ_CST); + queued -= frames; + } + } + else + { + drained_at = emscripten_get_now(); + } + if (queued < 0) + { + /* the worklet ran ahead of the ring (an underrun) */ + __atomic_store_n(&shared_state.audio_read, write, __ATOMIC_SEQ_CST); + queued = 0; + } + if (audio_stream.resumed && queued < AUDIO_TARGET_FRAMES) + { + int wanted = AUDIO_TARGET_FRAMES - queued; + + audio_stream.callback(audio_stream.userdata, (SDL_AudioStream *)&audio_stream, + wanted * AUDIO_CHANNELS * (int)sizeof(float), wanted * AUDIO_CHANNELS * (int)sizeof(float)); + } + usleep(4000); + } + return NULL; +} + +SDL_AudioStream *SDL_OpenAudioDeviceStream(SDL_AudioDeviceID device, const SDL_AudioSpec *spec, + SDL_AudioStreamCallback callback, void *userdata) +{ + (void)device; + if (!spec || spec->format != SDL_AUDIO_F32 || spec->channels != AUDIO_CHANNELS || !callback) + { + set_error("unsupported audio format"); + return NULL; + } + if (audio_stream.callback) + { + set_error("the audio device is open"); + return NULL; + } + audio_stream.callback = callback; + audio_stream.userdata = userdata; + audio_stream.rate = spec->freq; + shared_state.audio_rate = spec->freq; + if (pthread_create(&audio_thread, NULL, audio_thread_main, NULL) != 0) + { + audio_stream.callback = NULL; + set_error("cannot start the audio thread"); + return NULL; + } + pthread_detach(audio_thread); + return (SDL_AudioStream *)&audio_stream; +} + +bool SDL_PutAudioStreamData(SDL_AudioStream *stream, const void *data, int length) +{ + const float *samples = data; + int frames = length / (AUDIO_CHANNELS * (int)sizeof(float)); + int write = __atomic_load_n(&shared_state.audio_write, __ATOMIC_SEQ_CST); + int index; + + (void)stream; + for (index = 0; index < frames; index++) + { + int slot = (write + index) & (WEB_AUDIO_RING_FRAMES - 1); + + shared_state.audio_ring[slot * 2] = samples[index * 2]; + shared_state.audio_ring[slot * 2 + 1] = samples[index * 2 + 1]; + } + __atomic_store_n(&shared_state.audio_write, write + frames, __ATOMIC_SEQ_CST); + return true; +} + +bool SDL_ResumeAudioStreamDevice(SDL_AudioStream *stream) +{ + (void)stream; + audio_stream.resumed = 1; + return true; +} diff --git a/port/web/src/web_shared.h b/port/web/src/web_shared.h new file mode 100644 index 0000000000..834bdebe10 --- /dev/null +++ b/port/web/src/web_shared.h @@ -0,0 +1,157 @@ +/* +WEB_SHARED.H + +The state the page (port/web/site/app.js, on the browser's main thread) +shares with the game (a pthread, in a Web Worker) through the WebAssembly +memory. The page finds it with web_shared_state() and reads and writes it +through typed arrays at the offsets below: every member is a 32-bit word, +so the layout is the same on both sides. + +- events: keyboard, mouse and focus events, a ring the page writes and + SDL_PollEvent reads (web_sdl.c); +- gamepads: the state of up to WEB_GAMEPAD_COUNT controllers (the page + polls the Gamepad API each animation frame; the touch controls are one + more controller); +- display: the size of the page's canvas in device pixels, and a counter + the page advances each animation frame, which paces the game's frames; +- audio: a ring of interleaved stereo float samples the game's mixer + fills and the page's AudioWorklet plays. +*/ + +#ifndef __HALO_WEB_SHARED_H +#define __HALO_WEB_SHARED_H + +#include + +#define WEB_SHARED_MAGIC 0x42455748u /* 'HWEB' */ +#define WEB_SHARED_VERSION 1 + +#define WEB_EVENT_CAPACITY 256 +#define WEB_GAMEPAD_COUNT 5 +#define WEB_AUDIO_RING_FRAMES 8192 /* a power of two */ +/* the rings of packets to and from the other players (web_net.c, net.js) */ +#define WEB_NET_OUT_BYTES (1024 * 1024) /* powers of two */ +#define WEB_NET_IN_BYTES (2 * 1024 * 1024) +#define WEB_GATEWAY_PEERS 32 +#define WEB_PING_PEERS 128 +#define WEB_PING_STALE_MS 10000 + +enum +{ + WEB_EVENT_NONE, + WEB_EVENT_KEY, /* a: scancode, b: down, c: repeat, d: keycode */ + WEB_EVENT_MOUSE_MOTION, /* x, y: relative motion in pixels */ + WEB_EVENT_MOUSE_BUTTON, /* a: SDL button, b: down */ + WEB_EVENT_MOUSE_WHEEL, /* y: notches */ + WEB_EVENT_FOCUS, /* a: gained */ + WEB_EVENT_GAMEPAD_ADDED,/* a: id */ + WEB_EVENT_QUIT, +}; + +struct web_event +{ + int32_t type; + int32_t a, b, c, d; + float x, y; + int32_t reserved; +}; + +/* the SDL_GamepadButton bits and SDL_GamepadAxis values */ +struct web_gamepad +{ + int32_t connected; + int32_t id; /* the SDL_JoystickID, never 0 */ + int32_t type; /* SDL_GamepadType */ + uint32_t buttons; /* 1 << SDL_GamepadButton */ + int32_t axes[6]; /* SDL_GamepadAxis order, -32768..32767 (triggers 0..32767) */ + int32_t rumble_low, rumble_high; /* written by the game, 0..65535 */ + int32_t rumble_serial; /* advanced with each rumble request */ + int32_t rumble_milliseconds; +}; + +struct web_shared_state +{ + uint32_t magic; + uint32_t version; + uint32_t size; /* sizeof(struct web_shared_state) */ + + /* events: the page writes at event_write, the game reads at + event_read; both count without wrapping */ + volatile int32_t event_write; + volatile int32_t event_read; + struct web_event events[WEB_EVENT_CAPACITY]; + + struct web_gamepad gamepads[WEB_GAMEPAD_COUNT]; + + /* the canvas in device pixels, and the animation frame counter */ + volatile int32_t display_width; + volatile int32_t display_height; + volatile int32_t frame_counter; + /* frames the game presented, and whether it waits for each animation + frame (display.vsync) */ + volatile int32_t frames_presented; + volatile int32_t vsync; + + /* audio: sample rate, and the ring (WEB_AUDIO_RING_FRAMES stereo + frames); the game writes at audio_write, the worklet reads at + audio_read, both counting frames without wrapping */ + volatile int32_t audio_rate; + volatile int32_t audio_open; + volatile int32_t audio_write; + volatile int32_t audio_read; + volatile int32_t audio_underruns; + float audio_ring[WEB_AUDIO_RING_FRAMES * 2]; + + /* the page's state for the game: nonzero while it is hidden */ + volatile int32_t page_hidden; + /* the game's state for the page: 1 once the game's window opened */ + volatile int32_t game_started; + volatile int32_t reserved[16]; + + /* online play (web_net.c, port/web/site/net.js): this machine's address + on the players' network (network byte order, from the page), and the + packets the game sends to other machines and receives from them; each + ring counts bytes without wrapping */ + volatile int32_t net_local_address; + volatile int32_t net_out_write; + volatile int32_t net_out_read; + volatile int32_t net_in_write; + volatile int32_t net_in_read; + unsigned char net_out[WEB_NET_OUT_BYTES]; + unsigned char net_in[WEB_NET_IN_BYTES]; + /* Native invite relay identity. Peer address is published last; zero + means an unused slot. Identifiers are two little-endian words (6 bytes). */ + volatile int32_t gateway_enabled; + volatile int32_t gateway_identifier[2]; + volatile int32_t gateway_peers[WEB_GATEWAY_PEERS][3]; + /* Host RTT table: page publishes under an odd/even sequence lock. All + addresses use the shared network byte order; -1 means no fresh sample. */ + volatile int32_t ping_sequence, ping_host, ping_epoch, ping_updated, ping_count; + volatile int32_t ping_peers[WEB_PING_PEERS][2]; +}; + +/* a packet in the rings: this header, then the payload, padded to 4 bytes; +addresses and ports in network byte order */ +enum +{ + WEB_PACKET_DATAGRAM = 1, + WEB_PACKET_OPEN, /* a stream connects */ + WEB_PACKET_DATA, /* a stream's bytes */ + WEB_PACKET_CLOSE, /* a stream's end closed */ + WEB_PACKET_REFUSE, /* nothing listens where a stream connected */ +}; + +struct web_packet_header +{ + uint32_t size; /* the whole packet, padded */ + uint32_t kind; + uint32_t source_ip; + uint32_t destination_ip; + uint16_t source_port; + uint16_t destination_port; + uint32_t length; /* of the payload */ +}; + +struct web_shared_state *web_shared_state(void); + +#endif diff --git a/port/web/src/web_stubs.c b/port/web/src/web_stubs.c new file mode 100644 index 0000000000..dbe172de32 --- /dev/null +++ b/port/web/src/web_stubs.c @@ -0,0 +1,66 @@ +/* +WEB_STUBS.C + +What the platform layer links against that a browser does not have: the +router (UPnP) and Discord for internet play, which stays off in the web +build (web_main.c), a C library function Emscripten names differently and +an MSVC intrinsic clang knows only on x86 and ARM. +*/ + +#include +#include +#include +#include +#include + +typedef unsigned int posix_ulong; + +/* ---------- internet play (p2p.c) */ + +int posix_upnp_forward_udp(unsigned short port, unsigned short preferred_port, posix_ulong *external_address, unsigned short *external_port, + char *error, int error_size) +{ + (void)port; + (void)preferred_port; + (void)external_address; + (void)external_port; + if (error && error_size > 0) + { + strncpy(error, "UPnP is not available in a browser", (size_t)error_size - 1); + error[error_size - 1] = '\0'; + } + return 0; +} + +void posix_upnp_stop_forwarding_udp(unsigned short external_port) +{ + (void)external_port; +} + +/* ---------- the C library (posix_net.c) */ + +ssize_t getrandom(void *buffer, size_t size, unsigned int flags) +{ + unsigned char *cursor = buffer; + size_t left = size; + + (void)flags; + while (left) + { + /* getentropy gives at most 256 bytes at a time */ + size_t chunk = left > 256 ? 256 : left; + + if (getentropy(cursor, chunk) != 0) + return size - left ? (ssize_t)(size - left) : -1; + cursor += chunk; + left -= chunk; + } + return (ssize_t)size; +} + +/* ---------- MSVC (scenario.c): a compiler barrier */ + +void _ReadWriteBarrier(void) +{ + __atomic_signal_fence(__ATOMIC_SEQ_CST); +} diff --git a/port/web/stamp_version.py b/port/web/stamp_version.py new file mode 100644 index 0000000000..ceae353447 --- /dev/null +++ b/port/web/stamp_version.py @@ -0,0 +1,21 @@ +"""Writes the web site's version.json (tools/web_build.py): a hash of the +site's files, which the service worker (site/sw.js) keeps each build under.""" + +import hashlib +import json +import sys + + +def main() -> None: + output, inputs = sys.argv[1], sys.argv[2:] + digest = hashlib.sha256() + for path in sorted(inputs): + with open(path, "rb") as file: + digest.update(path.encode()) + digest.update(file.read()) + with open(output, "w", encoding="utf-8") as file: + json.dump({"version": digest.hexdigest()[:16]}, file) + + +if __name__ == "__main__": + main() diff --git a/port/web/stream-batch.js b/port/web/stream-batch.js new file mode 100644 index 0000000000..24be9cf11f --- /dev/null +++ b/port/web/stream-batch.js @@ -0,0 +1,312 @@ +"use strict"; + +// WebGL command recording for Halo's append-only streamed vertex +// and index buffers. The default path merges a frame's disjoint uploads into one +// upload at its first upload (or numeric bufferData orphan), before replaying draws. +// Halo's stream allocator writes each range before first use and advances the +// offset monotonically within the frame; overlapping writes disable the merge. +// Bounded CPU shadows retain untouched bytes in the persistent triple-buffer ring. +// Readbacks, texture transfers and buffer mutations outside this pattern flush +// the queue. Use ?batch_streams=0 to compare with the unmodified GL call path. +(() => { + const commandLimit = 40000; + const installed = new WeakMap(); + // The source-built runtime installs this directly on its worker-owned + // OffscreenCanvas context. It must flush before transferring the bitmap; + // that worker never returns to its event loop or schedules animation frames. + const install = gl => { + if (installed.has(gl)) return installed.get(gl); + const native = {}; + const methodNames = new Set([...Object.getOwnPropertyNames(WebGLRenderingContext.prototype), ...Object.getOwnPropertyNames(WebGL2RenderingContext.prototype)]); + for (const name of methodNames) if (name !== "constructor" && typeof gl[name] === "function") native[name] = gl[name].bind(gl); + let commands = [], bufferCommands = [], bytes = 0, flushing = false; + // Keep snapshots until replay without allocating a new backing store + // for every draw. Native GL consumes the bytes before this arena is + // reused. Align views for every uniform element type (including f64). + const snapshotLimit = 16 * 1024 * 1024; + const snapshots = new Uint8Array(snapshotLimit); + let snapshotOffset = 0; + const buffers = new Map(), elementBuffers = new Map(), storage = new WeakMap(); + let vao = null, drawFramebuffer = null; + const bound = target => target === gl.ELEMENT_ARRAY_BUFFER ? elementBuffers.get(vao) : buffers.get(target); + // These are the actual Halo streaming capacities: Android/browser uses + // three 16MiB/2MiB buffers; the desktop path uses 32MiB/8MiB orphans. + // Do not infer append-only behavior for other dynamic game buffers. + const streamStorage = (target, size, usage) => usage === gl.STREAM_DRAW && + ((target === gl.ARRAY_BUFFER && (size === 16 * 1024 * 1024 || size === 32 * 1024 * 1024)) || + (target === gl.ELEMENT_ARRAY_BUFFER && (size === 2 * 1024 * 1024 || size === 8 * 1024 * 1024))); + const stats = { flushes: 0, commands: 0, batches: 0, uploadsSaved: 0, mergedBytes: 0, recordBytes: 0, numericAllocations: 0, typedAllocations: 0, eligibleGroups: 0, overlappingGroups: 0, outOfRangeGroups: 0, uploadsWithoutOrphan: 0, groupsWithMultipleUploads: 0, persistentGroups: 0 }; + const allocationKinds = new Map(), uploadTargets = new Map(); + const countKind = (map, key) => { if (map.size < 32 || map.has(key)) map.set(key, (map.get(key) || 0) + 1); }; + const flush = () => { + if (flushing || !commands.length) return; + flushing = true; + const work = commands, bufferWork = bufferCommands; + commands = []; + bufferCommands = []; + bytes = 0; + const candidates = new Map(); + for (const command of bufferWork) { + const [name, args, buffer, info] = command; + if (name === "bufferData") { + // Match Halo's streaming allocations only. Arbitrary persistent + // buffers may be overwritten between draws and must stay ordered. + if (buffer && info?.shadow && streamStorage(args[0], args[1], args[2])) { + const group = { command, size: args[1], writes: [], first: Infinity, last: 0, valid: true, info }; + stats.eligibleGroups++; + candidates.set(buffer, group); + command.batch = group; + } else candidates.delete(buffer); + } else if (name === "bufferSubData") { + let group = candidates.get(buffer); + if (!group && info?.shadow) { + group = { command, size: info.size, writes: [], first: Infinity, last: 0, valid: true, info, persistent: true }; + candidates.set(buffer, group); + command.batch = group; + stats.persistentGroups++; + } + if (group) { + const first = args[1], last = first + args[2].byteLength; + if (first < 0 || last > group.size) { if (group.valid) stats.outOfRangeGroups++; group.valid = false; } + if (first < group.last) { if (group.valid) stats.overlappingGroups++; group.valid = false; } + group.writes.push(command); + group.first = Math.min(group.first, first); + group.last = Math.max(group.last, last); + if (group.writes.length === 2) stats.groupsWithMultipleUploads++; + } else stats.uploadsWithoutOrphan++; + } + } + for (const command of work) { + if (command.skip) continue; + const [name, args] = command; + const group = command.batch; + const merge = group?.valid && group.writes.length > 1 && group.last - group.first <= 16 * 1024 * 1024; + if (!merge || !group.persistent) native[name](...args); + if (merge) { + // Each recorded upload already updated this shadow. GL consumes the + // source synchronously, so the existing span can be submitted + // without allocating and filling another merged byte buffer. + const merged = group.info.shadow.subarray(group.first, group.last); + for (const write of group.writes) write.skip = true; + native.bufferSubData(args[0], group.first, merged); + stats.batches++; + stats.uploadsSaved += group.writes.length - 1; + stats.mergedBytes += merged.byteLength; + } + } + stats.flushes++; + stats.commands += work.length; + snapshotOffset = 0; + flushing = false; + }; + const snapshot = (data, sourceOffset = 0, length = data.length) => { + const source = sourceOffset || length !== data.length ? data.subarray(sourceOffset, sourceOffset + length) : data; + const size = source.byteLength; + if (size > snapshotLimit) return null; + let offset = (snapshotOffset + 7) & ~7; + if (offset + size > snapshotLimit || bytes + size > snapshotLimit) { + flush(); + offset = 0; + } + const copy = new data.constructor(snapshots.buffer, offset, source.length); + copy.set(source); + snapshotOffset = offset + size; + bytes += size; + return copy; + }; + const updateShadow = (info, offset, data) => { + if (!info || offset < 0 || offset + data.byteLength > info.size) return; + const end = offset + data.byteLength; + if (end > info.shadow.length) { + // Retain only the used part of a streaming buffer. Most frames + // use a fraction of the 16 MiB capacity of each ring slot. + let size = Math.max(65536, info.shadow.length); + while (size < end) size *= 2; + const grown = new Uint8Array(Math.min(size, info.size)); + grown.set(info.shadow); + info.shadow = grown; + } + info.shadow.set(data, offset); + }; + const record = (name, args, buffer, info) => { + const command = [name, args, buffer, info]; + commands.push(command); + if (buffer) bufferCommands.push(command); + if (commands.length > commandLimit || bytes > 16 * 1024 * 1024) flush(); + }; + const simple = [ + "activeTexture", "bindTexture", "bindFramebuffer", "bindRenderbuffer", "bindSampler", "bindBufferBase", "bindBufferRange", + "blendColor", "blendEquation", "blendEquationSeparate", "blendFunc", "blendFuncSeparate", "clear", "clearBufferfi", "clearColor", "clearDepth", "clearStencil", + "colorMask", "cullFace", "depthFunc", "depthMask", "depthRange", "disable", "enable", "frontFace", "hint", "lineWidth", "pixelStorei", "polygonOffset", "sampleCoverage", "scissor", "viewport", + "stencilFunc", "stencilFuncSeparate", "stencilMask", "stencilMaskSeparate", "stencilOp", "stencilOpSeparate", + "framebufferRenderbuffer", "framebufferTexture2D", "framebufferTextureLayer", "renderbufferStorage", "renderbufferStorageMultisample", + "samplerParameteri", "samplerParameterf", "texParameteri", "texParameterf", "useProgram", + "enableVertexAttribArray", "disableVertexAttribArray", "vertexAttribPointer", "vertexAttribIPointer", "vertexAttribDivisor", + "drawArrays", "drawElements", "drawRangeElements", "drawArraysInstanced", "drawElementsInstanced", "blitFramebuffer", + "beginQuery", "endQuery", "bindTransformFeedback", "beginTransformFeedback", "endTransformFeedback", "pauseTransformFeedback", "resumeTransformFeedback", "flush", "deleteBuffer", "deleteTexture", "deleteSampler", "deleteProgram", "deleteVertexArray", "deleteFramebuffer", "deleteRenderbuffer", "deleteQuery", + "uniform1f", "uniform2f", "uniform3f", "uniform4f", "uniform1i", "uniform2i", "uniform3i", "uniform4i", "uniform1ui", "uniform2ui", "uniform3ui", "uniform4ui", + "vertexAttrib1f", "vertexAttrib2f", "vertexAttrib3f", "vertexAttrib4f", "vertexAttribI4i", "vertexAttribI4ui", + ]; + for (const name of simple) if (native[name]) { + gl[name] = name.startsWith("uniform") + ? (...args) => { if (args[0] !== null) record(name, args); } + : (...args) => record(name, args); + } + for (const name of ["bindBufferBase", "bindBufferRange"]) if (native[name]) { + gl[name] = (target, index, buffer, ...rest) => { + if (target === gl.TRANSFORM_FEEDBACK_BUFFER && buffer) { + // A pending group must finish before GPU writes can alter its + // storage; invalidating only future snapshots is insufficient. + flush(); + storage.delete(buffer); + } + buffers.set(target, buffer); + record(name, [target, index, buffer, ...rest]); + }; + } + gl.bindFramebuffer = (target, framebuffer) => { + if (target === gl.FRAMEBUFFER || target === gl.DRAW_FRAMEBUFFER) drawFramebuffer = framebuffer; + record("bindFramebuffer", [target, framebuffer]); + }; + gl.deleteFramebuffer = framebuffer => { + record("deleteFramebuffer", [framebuffer]); + if (framebuffer && drawFramebuffer === framebuffer) drawFramebuffer = null; + }; + gl.blitFramebuffer = (...args) => { + record("blitFramebuffer", args); + // Halo's final blit presents its offscreen backbuffer to the canvas. + // Flush here also when vsync is disabled or the page runs hidden: + // those refresh paths do not schedule requestAnimationFrame. + if (drawFramebuffer === null) flush(); + }; + gl.bindVertexArray = next => { vao = next; record("bindVertexArray", [next]); }; + gl.bindBuffer = (target, buffer) => { + if (target === gl.ELEMENT_ARRAY_BUFFER) elementBuffers.set(vao, buffer); + else buffers.set(target, buffer); + record("bindBuffer", [target, buffer]); + }; + gl.deleteBuffer = buffer => { + record("deleteBuffer", [buffer]); + if (!buffer) return; + storage.delete(buffer); + for (const [target, current] of buffers) if (current === buffer) buffers.set(target, null); + if (elementBuffers.get(vao) === buffer) elementBuffers.set(vao, null); + }; + gl.deleteVertexArray = array => { + record("deleteVertexArray", [array]); + if (!array) return; + elementBuffers.delete(array); + if (vao === array) vao = null; + }; + gl.bufferData = (target, data, usage, ...rest) => { + countKind(allocationKinds, `${target}:${typeof data === "number" ? data : data.byteLength}:${usage}`); + const buffer = bound(target); + if (typeof data === "number") { + stats.numericAllocations++; + let info; + if (buffer && streamStorage(target, data, usage)) { + info = { size: data, usage, shadow: new Uint8Array(0) }; + storage.set(buffer, info); + } else if (buffer) storage.delete(buffer); + record("bufferData", [target, data, usage], buffer, info); + } else { stats.typedAllocations++; if (buffer) storage.delete(buffer); flush(); native.bufferData(target, data, usage, ...rest); } + }; + gl.bufferSubData = (target, offset, data, sourceOffset = 0, length = 0) => { + countKind(uploadTargets, String(target)); + const unit = data.BYTES_PER_ELEMENT || 1; + const count = length || (data.byteLength / unit - sourceOffset); + const source = new Uint8Array(data.buffer, data.byteOffset + sourceOffset * unit, count * unit); + const copy = snapshot(source); + const buffer = bound(target), info = buffer && storage.get(buffer); + if (!copy) { + // A single oversized write is consumed synchronously instead + // of exceeding the recorder's memory bound. + flush(); + if (buffer) storage.delete(buffer); + native.bufferSubData(target, offset, data, sourceOffset, length); + return; + } + stats.recordBytes += copy.byteLength; + updateShadow(info, offset, copy); + record("bufferSubData", [target, offset, copy], buffer, info); + }; + for (const name of methodNames) { + if (!native[name]) continue; + if (/^uniform(?:[1234][fiu]v|Matrix\w+fv)$/.test(name)) { + gl[name] = (...args) => { + if (args[0] === null) return; + const dataIndex = name.includes("Matrix") ? 2 : 1; + const data = args[dataIndex]; + const offset = args[dataIndex + 1] || 0; + const length = args[dataIndex + 2] || data.length - offset; + let copy; + if (ArrayBuffer.isView(data)) { + copy = snapshot(data, offset, length); + if (!copy) { flush(); native[name](...args); return; } + } else { + copy = data.slice(offset, offset + length); + bytes += copy.length * 8; + } + const result = args.slice(0, dataIndex).concat([copy]); + record(name, result); + }; + } else if (/^clearBuffer(?:fv|iv|uiv)$/.test(name)) { + gl[name] = (buffer, drawbuffer, values, sourceOffset = 0) => { + const count = buffer === gl.COLOR ? 4 : 1; + record(name, [buffer, drawbuffer, values.slice(sourceOffset, sourceOffset + count)]); + }; + } else if (/^vertexAttrib(?:[1234]fv|I4[iu]v)$/.test(name) || name === "drawBuffers") { + gl[name] = (...args) => record(name, args.map(value => ArrayBuffer.isView(value) ? value.slice() : Array.isArray(value) ? value.slice() : value)); + } + } + // These commands either observe mutable GL state or depend on immediate + // object allocation/transfer. Flush to preserve their synchronous behavior. + for (const name of methodNames) { + if (!native[name] || (simple.includes(name) || ["bindVertexArray", "bindBuffer", "bufferData", "bufferSubData"].includes(name) || /^uniform(?:[1234][fiu]v|Matrix\w+fv)$/.test(name) || /^vertexAttrib(?:[1234]fv|I4[iu]v)$/.test(name) || name === "drawBuffers" || /^clearBuffer[fiuv]+$/.test(name))) continue; + if (/^(create|get|is|check|read|texImage|texSubImage|compressedTex|copy|flush|finish|fenceSync|clientWaitSync|waitSync|invalidate|linkProgram|validateProgram|generateMipmap)/.test(name) && + !["getUniformLocation", "getActiveUniform", "getActiveAttrib", "getAttribLocation", "getShaderParameter", "getShaderInfoLog", "getShaderSource", "getShaderPrecisionFormat", "getProgramParameter", "getProgramInfoLog", "getUniformBlockIndex", "getActiveUniformBlockParameter", "getActiveUniformBlockName", "getActiveUniforms", "getUniformIndices", "getExtension", "getSupportedExtensions"].includes(name)) { + gl[name] = (...args) => { flush(); return native[name](...args); }; + } + } + // GPU-side copies bypass our CPU snapshots. Conservatively stop batching + // that destination until a fresh stream allocation establishes its bytes. + if (native.copyBufferSubData) gl.copyBufferSubData = (readTarget, writeTarget, ...args) => { + flush(); + native.copyBufferSubData(readTarget, writeTarget, ...args); + const destination = bound(writeTarget); + if (destination) storage.delete(destination); + }; + const controller = { + flush, + snapshot: () => ({ ...stats, commandLimit, + allocationKinds: Object.fromEntries(allocationKinds), uploadTargets: Object.fromEntries(uploadTargets) }), + resetStats: () => { for (const key of Object.keys(stats)) stats[key] = 0; }, + }; + installed.set(gl, controller); + return controller; + }; + globalThis.HaloStreamBatch = { install }; + + // Retain the publisher/Apollo page integration. The reusable installer + // above has no document, window, location or timer dependency. + if (typeof document === "undefined" || typeof window === "undefined") return; + if (new URLSearchParams(location.search).get("batch_streams") === "0") return; + const canvas = document.getElementById("canvas"); + if (!canvas) return; + const getContext = canvas.getContext.bind(canvas); + let wrapped = false; + canvas.getContext = (type, options) => { + const gl = getContext(type, options); + if (!gl || wrapped || type !== "webgl2") return gl; + wrapped = true; + const controller = install(gl); + const requestFrame = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = callback => { controller.flush(); return requestFrame(callback); }; + setInterval(() => { + const output = document.getElementById("performance-stats"); + if (output) output.dataset.streamBatch = JSON.stringify(controller.snapshot()); + controller.resetStats(); + }, 1000); + return gl; + }; +})(); diff --git a/port/web/vertex-state-cache.js b/port/web/vertex-state-cache.js new file mode 100644 index 0000000000..4e1d799adc --- /dev/null +++ b/port/web/vertex-state-cache.js @@ -0,0 +1,103 @@ +"use strict"; + +// Suppress redundant VAO enable bits and sampler bindings before stream +// recording. Load after stream-batch.js (and replay-cache.js when present). +// Buffer pointers, uploads and draws retain their original order. +// Use ?vertex_state_cache=0 to compare with the uncached calls. +(() => { + if (new URLSearchParams(location.search).get("vertex_state_cache") === "0") return; + const canvas = document.getElementById("canvas"); + const getContext = canvas.getContext.bind(canvas); + let wrapped = false; + canvas.getContext = (type, options) => { + const gl = getContext(type, options); + if (!gl || wrapped || type !== "webgl2") return gl; + wrapped = true; + const native = {}; + for (const name of ["getParameter", "createVertexArray", "bindVertexArray", "deleteVertexArray", + "enableVertexAttribArray", "disableVertexAttribArray", "createSampler", "bindSampler", "deleteSampler"]) + native[name] = gl[name].bind(gl); + const maxAttributes = native.getParameter(gl.MAX_VERTEX_ATTRIBS); + const maxUnits = native.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS); + const stats = { attributeCalls: 0, attributeSkipped: 0, samplerCalls: 0, samplerSkipped: 0 }; + let arrays, knownArrays, defaultArray, samplers, boundSamplers, currentArray, lost = false; + const reset = () => { + arrays = new WeakMap(); knownArrays = new WeakSet(); defaultArray = []; + samplers = new WeakSet(); boundSamplers = new Map(); currentArray = null; + }; + reset(); + gl.createVertexArray = () => { + const array = native.createVertexArray(); + if (array) { knownArrays.add(array); arrays.set(array, []); } + return array; + }; + gl.bindVertexArray = array => { + native.bindVertexArray(array); + // Unknown/deleted objects may fail to bind. Stop caching until an + // intercepted live object or the default VAO is explicitly bound. + if (array === null || knownArrays.has(array)) { + currentArray = array; + if (array && !arrays.has(array)) arrays.set(array, []); + } else { + // Later setters may mutate the last actual VAO. Invalidate all + // enable caches so rebinding it cannot reuse stale bits. + arrays = new WeakMap(); defaultArray = []; currentArray = undefined; + } + }; + gl.deleteVertexArray = array => { + native.deleteVertexArray(array); + if (!array) return; + knownArrays.delete(array); + arrays.delete(array); + if (currentArray === array) currentArray = null; + }; + for (const name of ["enableVertexAttribArray", "disableVertexAttribArray"]) { + const enabled = name === "enableVertexAttribArray"; + gl[name] = index => { + stats.attributeCalls++; + const state = currentArray === null ? defaultArray : arrays.get(currentArray); + if (lost || !state || !Number.isInteger(index) || index < 0 || index >= maxAttributes) { + // Coercible non-integer arguments can still mutate native + // state. Conservatively invalidate this VAO before forwarding. + if (state) state.length = 0; + return native[name](index); + } + if (state[index] === enabled) { stats.attributeSkipped++; return; } + native[name](index); + state[index] = enabled; + }; + } + gl.createSampler = () => { + const sampler = native.createSampler(); + if (sampler) samplers.add(sampler); + return sampler; + }; + gl.bindSampler = (unit, sampler) => { + stats.samplerCalls++; + if (lost || !Number.isInteger(unit) || unit < 0 || unit >= maxUnits || + (sampler !== null && !samplers.has(sampler))) { + boundSamplers.clear(); + return native.bindSampler(unit, sampler); + } + if (boundSamplers.has(unit) && boundSamplers.get(unit) === sampler) { + stats.samplerSkipped++; return; + } + native.bindSampler(unit, sampler); + boundSamplers.set(unit, sampler); + }; + gl.deleteSampler = sampler => { + native.deleteSampler(sampler); + if (!sampler) return; + samplers.delete(sampler); + for (const [unit, current] of boundSamplers) + if (current === sampler) boundSamplers.delete(unit); + }; + canvas.addEventListener("webglcontextlost", () => { lost = true; reset(); }); + canvas.addEventListener("webglcontextrestored", () => { lost = false; reset(); }); + setInterval(() => { + const output = document.getElementById("performance-stats"); + if (output) output.dataset.vertexStateCache = JSON.stringify(stats); + }, 1000); + return gl; + }; +})(); diff --git a/run-browser.command b/run-browser.command new file mode 100755 index 0000000000..ede5363a16 --- /dev/null +++ b/run-browser.command @@ -0,0 +1,3 @@ +#!/bin/sh +CDPATH= cd -P "$(dirname "$0")" || exit 1 +exec python3 tools/serve_browser.py "$@" diff --git a/source/bungie_net/network/transport_address.c b/source/bungie_net/network/transport_address.c index aa142cfb12..f9d9ae7dc2 100644 --- a/source/bungie_net/network/transport_address.c +++ b/source/bungie_net/network/transport_address.c @@ -104,11 +104,11 @@ symbols in this file: /* ---------- globals */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static char transport_address_string[256]; -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) && !defined(HALO_IOS) #pragma bss_seg() #endif diff --git a/source/cache/cache_files_windows.c b/source/cache/cache_files_windows.c index 6760fb28f3..a494812a03 100644 --- a/source/cache/cache_files_windows.c +++ b/source/cache/cache_files_windows.c @@ -1121,6 +1121,15 @@ static void cache_file_windows_thread_proc( return; } +#ifdef HALO_WEB +static DWORD WINAPI cache_file_windows_thread_start( + LPVOID parameter) +{ + cache_file_windows_thread_proc(); + return 0; +} +#endif + static void cache_file_windows_thread_create( void) { @@ -1132,7 +1141,12 @@ static void cache_file_windows_thread_create( cache_file_globals.thread = CreateThread( NULL, CACHE_FILE_THREAD_STACK_SIZE, +#ifdef HALO_WEB + /* a WebAssembly call must match the callee's signature */ + cache_file_windows_thread_start, +#else (LPTHREAD_START_ROUTINE)cache_file_windows_thread_proc, +#endif NULL, 0, NULL); diff --git a/source/cache/xbox_sound_cache.c b/source/cache/xbox_sound_cache.c index 3e82603cc9..46305bf29b 100644 --- a/source/cache/xbox_sound_cache.c +++ b/source/cache/xbox_sound_cache.c @@ -109,6 +109,12 @@ symbols in this file: #include +#ifdef HALO_WEB +/* called without a prototype in scope; a WebAssembly call must match the +definition's signature */ +short cache_file_read(long tag_index, long offset, long size, void *buffer, boolean *completion_flag_reference, boolean blocking); +#endif + /* ---------- constants */ /* ---------- macros */ diff --git a/source/cseries/stack_walk_windows.c b/source/cseries/stack_walk_windows.c index 70c20987b1..a25c6a5104 100644 --- a/source/cseries/stack_walk_windows.c +++ b/source/cseries/stack_walk_windows.c @@ -342,9 +342,9 @@ void stack_walk_with_context( for (frame_number = levels_dumped - 1; frame_number >= levels_to_ignore; frame_number--) { -#ifdef HALO_ANDROID +#ifdef HALO_ILP32 /* the call site (the BL before the return address), for - llvm-symbolizer --obj=build/android/halo_guest.elf */ + llvm-symbolizer --obj=build/ios/halo_guest.elf */ unsigned long routine_address = routine_addresses[frame_number] - 4; #else unsigned long routine_address = routine_addresses[frame_number] + *(long *)(routine_addresses[frame_number] - sizeof(long)); @@ -752,7 +752,12 @@ static int symbol_sort_proc( static boolean is_valid_ebp( void) { - return 0==(walk_up_current_frame & (sizeof(unsigned long) - 1)) && walk_up_current_frame >= (unsigned long)old_ebp; + return 0==(walk_up_current_frame & (sizeof(unsigned long) - 1)) && walk_up_current_frame >= (unsigned long)old_ebp +#ifdef HALO_IOS + /* Stop before crossing from the arena stack into the native caller. */ + && walk_up_current_frame - (unsigned long)old_ebp < 16 * 1024 * 1024 +#endif + ; } static unsigned long walk_up( @@ -760,9 +765,13 @@ static unsigned long walk_up( { unsigned long routine_address = 0; +#ifdef HALO_WEB + /* WebAssembly keeps its return addresses out of reach: no walk */ + walk_up_current_frame = 0; +#endif if (walk_up_current_frame) { -#ifdef HALO_ANDROID +#ifdef HALO_ILP32 /* an AArch64 frame record: the caller's frame pointer, then the return address, 8 bytes each (the upper halves are zero) */ routine_address = ((unsigned long *)walk_up_current_frame)[2]; diff --git a/source/effects/material_effects.c b/source/effects/material_effects.c index 47221aa09a..32c3d89a54 100644 --- a/source/effects/material_effects.c +++ b/source/effects/material_effects.c @@ -45,11 +45,11 @@ enum /* ---------- globals */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif boolean debug_material_effects; -#ifndef HALO_ANDROID +#ifndef HALO_ILP32 #pragma bss_seg() #endif diff --git a/source/game/game_engine.c b/source/game/game_engine.c index 9ec842482a..143b522209 100644 --- a/source/game/game_engine.c +++ b/source/game/game_engine.c @@ -567,6 +567,10 @@ symbols in this file: #include "math/integer_math.h" #include "networking/network_game_globals.h" #include "networking/network_server_manager.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "networking/network_migration.h" +#include "../../port/web/src/web_ping.h" +#endif #include "networking/network_game_manager.h" /* (network_server_manager_internal.h's: the host's game record) */ struct network_game *network_game_server_get_game(struct network_game_server *server); @@ -784,6 +788,10 @@ typedef char verify_game_engine_stage_size[ /* ---------- prototypes */ +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +boolean web_quick_play_pistol_starts(void); +#endif + void game_engine_playlist_next( long parameter0, long parameter1, @@ -856,6 +864,16 @@ static void game_engine_verify_current_map( void game_engine_post_rasterize_post_game( void); +struct bitmap_data *bitmap_group_try_and_get_bitmap( + long bitmap_group_index, + short bitmap_index); + +long hud_get_font_index( + void); + +real_argb_color *hud_get_text_color( + real_argb_color *color); + static boolean internal_rasterize_score( long player_index, long message, @@ -1390,8 +1408,12 @@ static void rasterize_in_game_score_draw_line( long row_index) { rectangle2d bounds = render.camera.window_bounds; - short narrow_tab_stops[3]; - short wide_tab_stops[3]; + short narrow_tab_stops[3 +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + + 1 +#endif + ]; + short wide_tab_stops[NUMBEROF(narrow_tab_stops)]; short *tab_stops; boolean splitscreen; long font_index; @@ -1404,6 +1426,13 @@ static void rasterize_in_game_score_draw_line( wide_tab_stops[0] = 130; wide_tab_stops[1] = 195; wide_tab_stops[2] = 315; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Leave room for the host label beside a full-length player name. */ + narrow_tab_stops[2] += 50; + wide_tab_stops[2] += 50; + narrow_tab_stops[3] = 280; + wide_tab_stops[3] = 440; +#endif if (bounds.x1 - bounds.x0 > 320) tab_stops = wide_tab_stops; @@ -1411,7 +1440,7 @@ static void rasterize_in_game_score_draw_line( tab_stops = narrow_tab_stops; if (row_index) - draw_string_set_tab_stops(tab_stops, 3); + draw_string_set_tab_stops(tab_stops, NUMBEROF(narrow_tab_stops)); else draw_string_set_tab_stops(NULL, 0); @@ -1583,6 +1612,65 @@ long populate_statistic_buffer( +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +static long game_engine_score_host_player(void) +{ + struct network_game_client *client = global_network_game_client_get(); + short host_machine, index; + long *players; + + if (!client || !network_game_is_active()) + return NONE; + host_machine = network_game_client_migration_host_machine(client); + if (host_machine < 0 || host_machine >= HALO_PORT_MAXIMUM_NETWORK_MACHINES) + return NONE; + players = machine_get_player_list(host_machine); + for (index = 0; index < MAXIMUM_LOCAL_PLAYERS; index++) + { + struct player_datum *player = player_try_and_get(players[index]); + /* Departed score datums can share a reused machine ID. Only the + current input owner is eligible for the host label. */ + if (player && !player->quit_out_of_game && + player->network_player_data.machine_index == host_machine) + return players[index]; + } + return NONE; +} + +static int game_engine_score_host_ping(long player_index) +{ + struct network_game_client *client = global_network_game_client_get(); + struct player_datum *player = player_try_and_get(player_index); + unsigned long address, host_address; + short machine, host_machine, index; + long *players; + if (!client || !network_game_is_active() || !player || player->quit_out_of_game) + return -1; + machine = player->network_player_data.machine_index; + host_machine = network_game_client_migration_host_machine(client); + if (machine < 0 || machine >= HALO_PORT_MAXIMUM_NETWORK_MACHINES || + host_machine < 0 || host_machine >= HALO_PORT_MAXIMUM_NETWORK_MACHINES) + return -1; + players = machine_get_player_list(machine); + for (index = 0; index < MAXIMUM_LOCAL_PLAYERS; index++) + if (players[index] == player_index) break; + if (index == MAXIMUM_LOCAL_PLAYERS) return -1; + if (global_network_game_server_get()) + { + unsigned long addresses[HALO_PORT_MAXIMUM_NETWORK_MACHINES] = { 0 }; + network_game_server_migration_routes(addresses, NUMBEROF(addresses)); + address = addresses[machine]; + host_address = addresses[host_machine]; + } + else + { + address = network_game_client_migration_owner_address(machine); + host_address = network_game_client_migration_owner_address(host_machine); + } + return web_net_host_ping(address, host_address, network_game_migration_epoch()); +} +#endif + static long select_players_to_display( enum postgame_statistic statistic, long player_index, @@ -1594,6 +1682,9 @@ static long select_players_to_display( boolean debug = rasterizer_debug_options.pad3 == 'E'; long local_player_count = 0; long statistic_index; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + long host_player_index = game_engine_score_host_player(); +#endif if (debug) { @@ -1615,14 +1706,22 @@ static long select_players_to_display( if (player_count > maximum_count) { long outside_range_count = 0; - struct statistic_buffer outside_range[MAXIMUM_LOCAL_PLAYERS]; + struct statistic_buffer outside_range[MAXIMUM_LOCAL_PLAYERS +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + + 1 +#endif + ]; long outside_range_index; for (statistic_index = maximum_count; statistic_index < player_count; statistic_index++) { struct player_datum *player = player_get(statistic_buffer[statistic_index].player_index); - if (player && player->local_player_index != NONE) + if (player && (player->local_player_index != NONE +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + || statistic_buffer[statistic_index].player_index == host_player_index +#endif + )) { if (debug) { @@ -1644,7 +1743,11 @@ static long select_players_to_display( { struct player_datum *player = player_get(statistic_buffer[insertion_index].player_index); - if (player->local_player_index == NONE) + if (player->local_player_index == NONE +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + && statistic_buffer[insertion_index].player_index != host_player_index +#endif + ) { csmemmove( &statistic_buffer[insertion_index], @@ -2059,13 +2162,15 @@ static void game_engine_rasterize_scoreboard( else usprintf(ping_string, L"%ld", ping); } - usprintf( - row_string, - L"\t%s\t%s\t%s\t%s", - get_place_string(entry), - player->name, - status_string, - ping_string); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + usnprintf(row_string, NUMBEROF(row_string), L"\t%s\t%s%s\t%s\t%s", + get_place_string(entry), player->name, + entry->player_index == game_engine_score_host_player() ? L" (HOST)" : L"", + status_string, ping_string); +#else + usprintf(row_string, L"\t%s\t%s\t%s\t%s", + get_place_string(entry), player->name, status_string, ping_string); +#endif row_color = has_teams ? &team_colors[PIN(player->team_index, 0, 1)] : &color; scoreboard_draw_row( row_string, @@ -2110,6 +2215,9 @@ static void game_engine_rasterize_in_game_score( long string_list_index; wchar_t *column_name; wchar_t *score_name; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + long host_player_index = game_engine_score_host_player(); +#endif /* port: a full-screen view's its own (game_engine_rasterize_scoreboard) */ if (local_player_count() <= 1) @@ -2150,7 +2258,11 @@ static void game_engine_rasterize_in_game_score( score_name = L""; game_engine->format_score_name(score_string); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + usnprintf(row_string, NUMBEROF(row_string), L"\t%s\t%s\t%s\tPing", column_name, score_name, score_string); +#else usprintf(row_string, L"\t%s\t%s\t%s", column_name, score_name, score_string); +#endif rasterize_in_game_score_draw_line(row_string, FALSE, &color, 1); for (entry_index = 0; entry_index < entry_count; entry_index++) @@ -2207,12 +2319,23 @@ static void game_engine_rasterize_in_game_score( place_string = get_place_string(&entries[entry_index]); - usprintf( - row_string, - L"\t%s\t%s\t%s", - place_string, - player->name, - status_string); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + { + wchar_t ping_string[16]; + int ping = game_engine_score_host_ping(entry_player_index); + if (ping >= 0) + usnprintf(ping_string, NUMBEROF(ping_string), L"%d", ping); + else + usnprintf(ping_string, NUMBEROF(ping_string), L"--"); + usnprintf(row_string, NUMBEROF(row_string), L"\t%s\t%s%s\t%s\t%s", + place_string, player->name, + entry_player_index == host_player_index ? L" (HOST)" : L"", + status_string, ping_string); + } +#else + usprintf(row_string, L"\t%s\t%s\t%s", + place_string, player->name, status_string); +#endif if (has_teams) row_color = &team_colors[PIN(player->team_index, 0, 1)]; @@ -4978,8 +5101,22 @@ boolean game_engine_should_end_game( void) { boolean should_end_game = FALSE; + boolean keep_solo_host = FALSE; + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* A browser Slayer host starts alone and stays open for late joiners. + Departed players retain their score datums, so the legacy last-team + check would end that match when its first opponent leaves. Score and + explicit end conditions and configured time limits still end the game. */ + if (game_engine && global_network_game_server_get() && network_game_distributed() && + game_engine->type == game_engine_slayer && !global_variant.universal_variant.teams && + global_variant.universal_variant.lives == 0) + { + keep_solo_host = TRUE; + } +#endif - if (game_engine && !multiple_teams_alive()) + if (game_engine && !keep_solo_host && !multiple_teams_alive()) should_end_game = TRUE; /* port: the gametype's time limit (game_variant_options) */ if (game_engine && game_variant_options_get()->time_limit > 0 && @@ -8365,6 +8502,16 @@ static void handle_custom_starting_equipment( long weapon_index; struct object_placement_data placement_data; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Change only the automatic lobby's plasma-pistol start. */ + if (web_quick_play_pistol_starts() && definition_index != NONE && + definition_index == tag_loaded('weap', "weapons\\plasma pistol\\plasma pistol")) + { + long pistol = tag_loaded('weap', "weapons\\pistol\\pistol"); + if (pistol != NONE) + definition_index = pistol; + } +#endif object_placement_data_new( &placement_data, definition_index, @@ -9006,3 +9153,71 @@ void game_engine_read_network_state( if (postgame_state != 0 && game_engine_globals.postgame_state == 0) game_engine_end_game(); } + +/* Unlike the regular game-state update this also preserves the authority's +postgame clock and team allocator. HUD timers belong to the local machine. */ +struct game_engine_migration_state +{ + long type; + unsigned long flags; + long next_team_index; + real postgame_timer; + real postgame_progress; + long postgame_state; + long state_size; +}; + +long game_engine_write_migration_state(byte *buffer, long size) +{ + struct game_engine_migration_state state; + long written; + if (size < (long)sizeof(state)) + return 0; + memset(&state, 0, sizeof(state)); + state.type = game_engine_get_type(); + state.flags = game_engine_globals.flags; + state.next_team_index = game_engine_globals.next_team_index; + state.postgame_timer = game_engine_globals.postgame_timer; + state.postgame_progress = game_engine_globals.postgame_progress; + state.postgame_state = game_engine_globals.postgame_state; + written = game_engine_write_network_state(buffer + sizeof(state), size - sizeof(state)); + if (written <= 0) + return 0; + state.state_size = written; + memcpy(buffer, &state, sizeof(state)); + return sizeof(state) + written; +} + +boolean game_engine_validate_migration_state(byte const *buffer, long size) +{ + struct game_engine_migration_state state; + byte current[0xF00]; + long expected; + if (size < (long)sizeof(state)) + return FALSE; + memcpy(&state, buffer, sizeof(state)); + expected = game_engine_write_network_state(current, sizeof(current)); + return game_engine && state.type == game_engine_get_type() && expected > 0 && + state.state_size == expected && state.state_size == size - (long)sizeof(state); +} + +boolean game_engine_read_migration_state(byte const *buffer, long size, boolean restore_game_type) +{ + struct game_engine_migration_state state; + if (!game_engine_validate_migration_state(buffer, size)) + return FALSE; + memcpy(&state, buffer, sizeof(state)); + /* Set the postgame state first so restoring it does not announce another +game end or invoke the end-game callback a second time. */ + game_engine_globals.flags = state.flags; + game_engine_globals.next_team_index = state.next_team_index; + if (restore_game_type || game_engine_globals.postgame_state == state.postgame_state) + { + game_engine_globals.postgame_timer = state.postgame_timer; + game_engine_globals.postgame_progress = state.postgame_progress; + game_engine_globals.postgame_state = state.postgame_state; + } + if (restore_game_type) + game_engine_read_network_state(buffer + sizeof(state), state.state_size); + return TRUE; +} diff --git a/source/game/game_engine.h b/source/game/game_engine.h index e846452fff..43c1c4f464 100644 --- a/source/game/game_engine.h +++ b/source/game/game_engine.h @@ -697,6 +697,9 @@ void game_engine_player_killed( /* ---------- globals */ extern struct game_engine *game_engine; +long game_engine_write_migration_state(byte *buffer, long size); +boolean game_engine_validate_migration_state(byte const *buffer, long size); +boolean game_engine_read_migration_state(byte const *buffer, long size, boolean restore_game_type); /* ---------- public code */ diff --git a/source/game/game_engine_stub.c b/source/game/game_engine_stub.c index b190175533..d9a310e6ac 100644 --- a/source/game/game_engine_stub.c +++ b/source/game/game_engine_stub.c @@ -85,6 +85,64 @@ static void stub_engine_player_killed_player(void); /* ---------- globals */ +#ifdef HALO_WEB +/* WebAssembly calls must match the callee's signature: the engine's +callbacks (struct game_engine) take arguments the stubs above ignore */ +static void stub_player_added(long player_index) { stub_engine_player_added(); } +static void stub_statistics_append(long statistic) { stub_engine_statistics_append(); } +static void stub_handle_client_message(void *message) { stub_engine_handle_client_message(); } +static void stub_handle_server_message(void *message) { stub_engine_handle_server_message(); } +static boolean stub_allow_pick_up(long unit_index, long weapon_index) { return stub_engine_allow_pick_up(); } +static void stub_player_damaged_player(long damaging_player_index, long dead_player_index, boolean damage_type) +{ + stub_engine_player_damaged_player(); +} +static void stub_player_killed_player(long killing_player_index, long killing_object_index, long dead_player_index, + boolean friendly_fire) +{ + stub_engine_player_killed_player(); +} + +struct stub_game_engine stub_engine = +{ + "stub", + _game_engine_type_stub, + { + stub_engine_dispose, + (stub_game_engine_callback) stub_engine_initialize_for_new_map, + stub_engine_dispose_from_old_map, + (stub_game_engine_callback) stub_player_added, + stub_engine_game_ending, + stub_engine_game_starting, + (stub_game_engine_callback) stub_statistics_append, + (stub_game_engine_callback) stub_handle_client_message, + (stub_game_engine_callback) stub_handle_server_message, + stub_engine_pregame_post_rasterize, + stub_engine_post_rasterize, + NULL, + NULL, + NULL, + NULL, + stub_engine_update, + NULL, + NULL, + NULL, + NULL, + (stub_game_engine_callback) stub_allow_pick_up, + (stub_game_engine_callback) stub_player_damaged_player, + (stub_game_engine_callback) stub_player_killed_player, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + }, +}; +#else struct stub_game_engine stub_engine = { "stub", @@ -124,6 +182,7 @@ struct stub_game_engine stub_engine = NULL, }, }; +#endif /* ---------- public code */ diff --git a/source/game/player_queues_new.c b/source/game/player_queues_new.c index 4f6317f6ee..7ac1c69564 100644 --- a/source/game/player_queues_new.c +++ b/source/game/player_queues_new.c @@ -1226,3 +1226,28 @@ static struct update *update_client_get_update( return NULL; } + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +void update_queues_migrate(void) +{ + struct data_iterator iterator; + struct update_server_queue_datum *queue; + long tick = game_time_get(); + csmemset(update_server_pending_control_flags, 0, sizeof(update_server_pending_control_flags)); + csmemset(update_client_relayed_actions, 0, sizeof(update_client_relayed_actions)); + csmemset(update_server_distributed_inputs, 0, sizeof(update_server_distributed_inputs)); + csmemset(update_client_local_inputs, 0, sizeof(update_client_local_inputs)); + if (!update_server_globals.initialized || !update_client_globals.initialized) + return; + update_server_start(); + update_server_globals.next_update_number_to_build = tick; + update_client_globals.next_update_number_to_dequeue = tick; + update_client_globals.latest_update_number_received = tick - 1; + csmemset(update_server_globals.updates, 0, sizeof(update_server_globals.updates)); + csmemset(update_client_globals.updates, NONE, sizeof(update_client_globals.updates)); + csmemset(&update_client_globals.saved_action_collection, 0, sizeof(update_client_globals.saved_action_collection)); + data_iterator_new(&iterator, update_server_globals.queues); + while ((queue = data_iterator_next(&iterator)) != NULL) + queue->next_update_number = tick; +} +#endif diff --git a/source/game/players.c b/source/game/players.c index a834d48162..ca2479dcaa 100644 --- a/source/game/players.c +++ b/source/game/players.c @@ -588,6 +588,23 @@ long *machine_get_player_list( return machine_to_player_table[DATUM_INDEX_TO_ABSOLUTE_INDEX(machine_index)]; } +void network_player_remove_from_machine( + long machine_index, + long player_index) +{ + long *player_list; + short index; + + if (machine_index < 0 || machine_index >= MAXIMUM_NETWORK_MACHINE_COUNT || player_index == NONE) + return; + player_list = machine_get_player_list(machine_index); + for (index = 0; index < MAXIMUM_LOCAL_PLAYERS; index++) + { + if (player_list[index] == player_index) + player_list[index] = NONE; + } +} + boolean local_player_exists( long local_player_index) { @@ -1205,7 +1222,8 @@ long player_new( csmemcpy(&player->network_player_data, network_player, sizeof(struct network_player)); } - machine_add_player(machine_index, player_index); + if (player_index != NONE) + machine_add_player(machine_index, player_index); return player_index; } diff --git a/source/game/players.h b/source/game/players.h index 11de42027e..516e995722 100644 --- a/source/game/players.h +++ b/source/game/players.h @@ -344,6 +344,9 @@ long *machine_get_player_list( void machine_remove_player( long player_index); +/* Stop accepting input for a departed player while retaining its game datum. */ +void network_player_remove_from_machine(long machine_index, long player_index); + long player_new( long machine_index, long player_index, diff --git a/source/interface/hud_nav_points.c b/source/interface/hud_nav_points.c index c0718e75c8..2f264e18ff 100644 --- a/source/interface/hud_nav_points.c +++ b/source/interface/hud_nav_points.c @@ -215,6 +215,29 @@ struct hud_nav_object_datum static void hud_update_nav_point_local_player( short local_player_index); +void *object_try_and_get_and_verify_type( + long object_index, + unsigned long valid_type_flags); + +void object_get_bounding_sphere( + long object_index, + real_point3d *center, + real *radius); + +real_point3d *game_engine_get_goal_position( + real_point3d *position, + short goal_index); + +void custom_render_nav_point( + short local_player_index, + real_point3d const *position, + short nav_index, + short render_type); + +void unit_get_head_position( + long unit_index, + real_point3d *head_position); + /* ---------- globals */ static struct hud_nav_point_player_datum *nav_point_data; diff --git a/source/interface/interface.c b/source/interface/interface.c index 996b8957cd..9edddb28f2 100644 --- a/source/interface/interface.c +++ b/source/interface/interface.c @@ -381,9 +381,13 @@ void interface_draw_fullscreen_overlays( cinematic_render(); interface_splitscreen_render(); hud_render_timer(); +#ifndef HALO_IOS + /* Keep developer console output and performance counters off the iOS + game picture. Diagnostics remain available in the log files. */ terminal_draw(); main_framerate_render(); render_debug_profile(); +#endif return; } diff --git a/source/interface/player_ui.c b/source/interface/player_ui.c index 201f413d4c..c3bbdb6ea8 100644 --- a/source/interface/player_ui.c +++ b/source/interface/player_ui.c @@ -635,7 +635,16 @@ void player_ui_fast_setup_network_server( dispose_global_network_game_server(); dispose_global_network_game_client(); game_connection_set(_game_connection_local); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Quick play supplies its initial map before playlist initialization; + ordinary System Link retains its original default selection. */ + { + extern char const *web_quick_play_initial_map(void); + main_set_multiplayer_map_name(web_quick_play_initial_map()); + } +#else main_set_multiplayer_map_name(""); +#endif player_ui_globals.multiplayer_variant_specified = FALSE; if (ui_widget_load_by_name_or_tag( "ui\\shell\\main_menu\\multiplayer_type_select\\connected\\pregame\\connected_pregame_screen", diff --git a/source/interface/terminal.c b/source/interface/terminal.c index 2a950a8616..c425d3dd18 100644 --- a/source/interface/terminal.c +++ b/source/interface/terminal.c @@ -347,7 +347,12 @@ void terminal_printf( ...) { real_argb_color default_terminal_printf_color; +#ifdef HALO_WEB + /* WebAssembly's va_list is not a char pointer */ + va_list arglist; +#else char *arglist; +#endif va_start(arglist, format); diff --git a/source/interface/ui_widget_event_handler_functions.c b/source/interface/ui_widget_event_handler_functions.c index 32e901658a..f7f8ff993b 100644 --- a/source/interface/ui_widget_event_handler_functions.c +++ b/source/interface/ui_widget_event_handler_functions.c @@ -2288,11 +2288,31 @@ static boolean player_profile_color_picker_menu_dispose( return TRUE; } +#ifdef HALO_IOS_BROWSER +void ios_browser_test_open(void) { + void ios_room_open(void); + dispose_global_network_game_client();dispose_global_network_game_server(); + main_goto_main_menu();ios_room_open(); +} +#endif + static boolean network_game_server_list_initialize( struct widget_instance *widget, struct event_record *event, boolean *widget_deleted) { +#ifdef HALO_IOS_BROWSER + /* WebRTC rooms use their own transport; native Internet/LAN use the game menus. */ + int host_ios_browser_mode(void); + char const *config_string(char const *name); + if (host_ios_browser_mode() || !strcmp(config_string("display.menus"), "xbox")) { void ios_room_open(void); + dispose_global_network_game_client(); + dispose_global_network_game_server(); + main_goto_main_menu(); + ios_room_open(); + return TRUE; } +#endif + boolean result = TRUE; dispose_global_network_game_client(); diff --git a/source/interface/ui_widget_game_data_input_functions.c b/source/interface/ui_widget_game_data_input_functions.c index 620a184c48..c053680390 100644 --- a/source/interface/ui_widget_game_data_input_functions.c +++ b/source/interface/ui_widget_game_data_input_functions.c @@ -1347,7 +1347,13 @@ static void network_pregame_status_screen_update( short seconds_to_game_start = network_game_client_get_seconds_to_game_start( global_network_game_client_get()); boolean waiting_for_machines = (global_network_game_server_get() && - game->machine_count < 2); + game->machine_count < +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + (network_game_distributed() ? 1 : 2) +#else + 2 +#endif + ); ustrncpy(countdown_text->parameters.text_box.text, L"-:--", 15); status_text->visible = TRUE; @@ -2007,6 +2013,9 @@ static void set_textbox_to_build_number( the function is unattested. January corroborates: .bss +0x28, referenced only here. */ static wchar_t build_number_string[64]; +#ifndef HALO_IOS + /* The iOS menu leaves this developer build label blank. Save and map + version checks still use their original build numbers. */ if (!build_number_string[0]) { ascii_to_wide( @@ -2014,6 +2023,7 @@ static void set_textbox_to_build_number( build_number_string, sizeof(build_number_string)); } +#endif if (!widget->parameters.text_box.text) { @@ -2908,7 +2918,19 @@ static void multiplayer_game_directions( { boolean waiting_for_machines = !network_game_is_splitscreen_local() && game && - game->machine_count < 2; + game->machine_count < +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + (network_game_distributed() ? 1 : 2) +#else + 2 +#endif + ; + { + widget->parameters.text_box.string_list_index = + _multiplayer_game_text_string_waiting_for_machine; + widget->visible = TRUE; + return; + } if (!waiting_for_machines && network_game_is_splitscreen_local() && diff --git a/source/items/projectiles.h b/source/items/projectiles.h index 70a1846482..5ec7b6aa18 100644 --- a/source/items/projectiles.h +++ b/source/items/projectiles.h @@ -34,6 +34,7 @@ struct _projectile_datum real detonation_timer; real detonation_timer_delta; real arming_time; + /* The complete runtime tail (projectiles.c keeps the same layout). */ real arming_time_delta; real odometer; real deceleration_timer; @@ -124,6 +125,7 @@ boolean projectile_aim_ballistic( void projectile_accelerate( long projectile_index, union real_vector3d const *acceleration); +boolean projectile_update(long projectile_index); /* ---------- globals */ diff --git a/source/main/main.c b/source/main/main.c index e35691e14c..86b901f0b6 100644 --- a/source/main/main.c +++ b/source/main/main.c @@ -389,6 +389,13 @@ symbols in this file: #include "text/font_group.h" #include "tag_files/files.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +/* called without a prototype in scope; a WebAssembly call must match the +definition's signature */ +boolean cache_files_give_time_to_precache(char const *map_name); +short player_ui_get_single_player_local_player_controller(short local_player_index); +#endif + /* ---------- constants */ enum @@ -656,6 +663,11 @@ typedef char screenshot_and_framerate_globals_size_assert[ sizeof(struct _screenshot_and_framerate_globals) == 0x38B ? 1 : -1]; void network_test_update(boolean main_menu_loaded, real seconds); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +void quick_play_update(boolean main_menu_loaded); +boolean web_match_migration_enabled(void); +boolean web_match_migration_lost(void); +#endif /* ---------- prototypes */ @@ -3186,6 +3198,11 @@ void main_loop( event_manager_update(); telnet_console_process(); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Production quick play owns only session setup, never player input. */ + quick_play_update(main_globals.main_menu_scenario_loaded); +#endif + if (!shell_application_is_paused()) { render_frame = TRUE; @@ -3197,24 +3214,39 @@ void main_loop( { if (!network_game_client_start_frame()) { - display_error_when_main_menu_loaded(6); - error(_error_silent, "the game host went down"); - network_game_abort(); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (!web_match_migration_lost()) +#endif + { + display_error_when_main_menu_loaded(6); + error(_error_silent, "the game host went down"); + network_game_abort(); + } } } else if (connection==_game_connection_network_server) { if (!network_game_client_start_frame()) { - display_error_when_main_menu_loaded(1); - error(_error_silent, "the game host went down"); - network_game_abort(); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (!web_match_migration_lost()) +#endif + { + display_error_when_main_menu_loaded(1); + error(_error_silent, "the game host went down"); + network_game_abort(); + } } else if (!network_game_server_start_frame()) { - display_error_when_main_menu_loaded(1); - error(_error_silent, "the game host went down"); - network_game_abort(); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (!web_match_migration_lost()) +#endif + { + display_error_when_main_menu_loaded(1); + error(_error_silent, "the game host went down"); + network_game_abort(); + } } } else if (connection==_game_connection_film_playback) @@ -3249,8 +3281,13 @@ void main_loop( connection = main_globals.connection; if (connection>_game_connection_local && connection<=_game_connection_network_server && !network_game_client_end_frame()) { - display_error_when_main_menu_loaded(1); - network_game_abort(); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (!web_match_migration_lost()) +#endif + { + display_error_when_main_menu_loaded(1); + network_game_abort(); + } } game_time_update((real)main_globals.halt_time_scale*main_globals.seconds_elapsed); diff --git a/source/math/random_math.c b/source/math/random_math.c index 4fc02dd628..5953a21fc1 100644 --- a/source/math/random_math.c +++ b/source/math/random_math.c @@ -92,11 +92,11 @@ direction3d_from_table( /* ---------- globals */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static struct random_math_globals random_math_globals; -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) && !defined(HALO_IOS) #pragma bss_seg() #endif diff --git a/source/memory/crc.c b/source/memory/crc.c index c2f4ad14c2..1631c6239d 100644 --- a/source/memory/crc.c +++ b/source/memory/crc.c @@ -39,11 +39,11 @@ struct crc_globals /* ---------- globals */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static struct crc_globals crc_globals; -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) && !defined(HALO_IOS) #pragma bss_seg() #endif diff --git a/source/networking/network_client_manager.c b/source/networking/network_client_manager.c index 35cac6bb46..e4f5f43c11 100644 --- a/source/networking/network_client_manager.c +++ b/source/networking/network_client_manager.c @@ -391,6 +391,10 @@ symbols in this file: #include "networking/network_messages.h" #include "networking/network_server_manager.h" #include "text/unicode.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "network_migration.h" +#include "../../port/linux/game/network_distributed.h" +#endif /* ---------- constants */ @@ -413,16 +417,6 @@ enum NETWORK_GAME_CLIENT_LINK_DOWN_TIMEOUT = 15000, }; -enum network_game_client_state -{ - _network_game_client_state_searching, - _network_game_client_state_joining, - _network_game_client_state_pregame, - _network_game_client_state_ingame, - _network_game_client_state_postgame, - NUMBER_OF_NETWORK_GAME_CLIENT_STATES, -}; - enum { _network_game_client_error_none = 0, @@ -800,6 +794,16 @@ struct network_game_client network_game_client_dont_use_directly; boolean allow_out_of_sync = FALSE; boolean network_game_client_dont_use_directly_in_use = FALSE; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +static struct +{ + boolean reconnecting, acknowledged; + unsigned long epoch, target, retry_at, attach_at; + short host_machine; + unsigned long owner_addresses[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; +} client_migration; +#endif + /* ---------- public code */ /* transport_network_available asks the system for its interfaces @@ -1050,6 +1054,13 @@ void network_game_client_game_shutdown( 0x3FC, client); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (client->state == _network_game_client_state_ingame && web_match_migration_enabled()) + { + web_match_migration_lost(); + return; + } +#endif network_game_client_set_error( client, _network_game_client_error_server_shutdown); @@ -1754,6 +1765,12 @@ boolean network_game_client_remove_player( machine_remove_player(player_index); } + /* A distributed machine slot may be reused before the old player's + datum is retired (it remains for the scoreboard). Its new players + must not inherit that datum's input ownership or fill its four slots. */ + if (network_game_distributed()) + network_player_remove_from_machine(player_datum->network_player_data.machine_index, player_index); + for (network_player_index = 0; network_player_index < MAXIMUM_NUMBER_OF_PLAYERS; network_player_index++) @@ -1892,6 +1909,21 @@ boolean network_game_client_update_local_player_data( return success; } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +static void network_game_client_sync_added_machine(struct network_game_client *client, + struct network_player const *player) +{ + if (client->state == _network_game_client_state_ingame && network_player_is_valid(player) && + !network_machine_is_valid(&client->game.machines[player->machine_index])) + { + struct network_machine *machine = &client->game.machines[player->machine_index]; + csmemset(machine, 0, sizeof(*machine)); + machine->machine_index = player->machine_index; + client->game.machine_count++; + } +} +#endif + boolean network_game_client_add_player_to_game( struct network_game_client *client, struct network_player *player) @@ -1909,6 +1941,11 @@ boolean network_game_client_add_player_to_game( if (success) { +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* A late join can reuse a departed host's slot. The player packet + also admits that machine to this client's preserved session roster. */ + network_game_client_sync_added_machine(client, player); +#endif if (client->state == _network_game_client_state_ingame) { /* port: the slot it went in, which in the distributed netcode's @@ -2192,6 +2229,10 @@ struct network_game_client *network_game_client_create( void) { struct network_game_client *client = &network_game_client_dont_use_directly; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + csmemset(&client_migration, 0, sizeof(client_migration)); + client_migration.epoch = web_quick_play_initial_epoch(); +#endif match_assert( "c:\\halo\\SOURCE\\networking\\network_client_manager.c", @@ -2810,19 +2851,80 @@ static boolean network_game_client_idle_pregame( return success; } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +static boolean network_game_client_idle_migration(struct network_game_client *client) +{ unsigned long now = system_milliseconds(); + if (!client->connection || !network_connection_active(client->connection) || + (!network_connection_connected(client->connection) && now - client_migration.retry_at >= 1000UL)) + { + if (now - client_migration.retry_at < 1000UL) + return TRUE; + client_migration.retry_at = now; + if (client->connection) + network_connection_delete(client->connection); + client->connection = network_connection_new(_network_connection_type_client, NETWORK_GAME_CLIENT_PORT); + if (!client->connection) + return TRUE; + { + struct transport_address address = { 0 }; + address.address_length = IPV4_ADDRESS_LENGTH; + address.address.long_words[0] = client_migration.target; + address.port = NETWORK_GAME_SERVER_PORT; + network_connection_connect(client->connection, &address, NULL); + } + } + if (!network_connection_idle(client->connection, 15000, NULL)) + { + /* Timeout leaves the old connection marked active/connected. Retire + it explicitly so the next bounded retry can open a fresh stream. */ + network_connection_delete(client->connection); + client->connection = NULL; + network_event("migration connection timed out; retrying machine #%d, epoch #%lu", + client->machine_index, client_migration.epoch); + return TRUE; + } + if (network_connection_connected(client->connection)) + { + if (!client_migration.acknowledged && now - client_migration.attach_at >= 500UL) + { + struct network_migration_message attach = { 0 }; + attach.type = NETWORK_MIGRATION_MESSAGE; + attach.version = NETWORK_MIGRATION_VERSION; + attach.epoch = client_migration.epoch; + attach.session_seed = client->game.random_seed; + attach.machine_index = client->machine_index; + attach.kind = NETWORK_MIGRATION_ATTACH; + build_message_header(&attach.header, sizeof(attach), 2, 0); + network_connection_write(client->connection, &attach, sizeof(attach), NULL, TRUE); + client_migration.attach_at = now; + } + network_game_client_process_incoming_messages(client); + } + return TRUE; +} +#endif + static boolean network_game_client_idle_ingame( struct network_game_client *client) { boolean success = TRUE; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (client_migration.reconnecting) + return network_game_client_idle_migration(client); +#endif /* port: until the host's first update the host may still be loading the map (its main thread, which sends nothing meanwhile), as long as it waits for the other machines to load (network_server_manager.c's NETWORK_GAME_SERVER_MAXIMUM_WAIT_TIME_FOR_LEVEL_LOADING) */ boolean started = network_game_client_server_has_started_game(client); - if (!network_connection_active(client->connection) || + if (!client->connection || !network_connection_active(client->connection) || !network_connection_connected(client->connection)) { +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (web_match_migration_lost()) + return TRUE; +#endif error(_error_silent, "new idle in game abort hit"); display_error_when_main_menu_loaded(_error_network_server_shut_down); success = FALSE; @@ -2877,6 +2979,10 @@ static boolean network_game_client_idle_ingame( if (!network_connection_active(client->connection) || !network_connection_connected(client->connection)) { +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (web_match_migration_lost()) + return TRUE; +#endif error(_error_silent, "new2 idle in game abort hit"); display_error_when_main_menu_loaded(_error_network_server_shut_down); success = FALSE; @@ -2886,6 +2992,13 @@ static boolean network_game_client_idle_ingame( } } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (!success && web_match_migration_enabled()) + { + web_match_migration_lost(); + return TRUE; + } +#endif return success; } @@ -3065,6 +3178,56 @@ boolean network_game_client_join_first_available_game( return FALSE; } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +short network_game_client_quick_join(unsigned long target_address) +{ + struct network_game_client *client = global_network_game_client_get(); + long index; + + if (!client || client->state != _network_game_client_state_searching || client->join_in_progress || + !client->connection || network_connection_connected(client->connection)) + return 0; + for (index = 0; index < MAXIMUM_NETWORK_ADVERTISED_GAMES; index++) + { + struct network_advertised_game *game = &client->available_games[index]; + struct transport_address address = { { { 0 } } }; + struct network_join_parameters parameters; + + if (!network_game_client_advertised_game_is_valid(game) || + game->platform != network_game_get_local_platform()) + continue; + transport_client_start((XNADDR const *)&game->xnaddr, (XNKEY const *)&game->key, + (XNKID const *)&game->key_id, NETWORK_GAME_SERVER_PORT, &address); + if (!address.address.long_words[0] || !address.port || + (target_address && address.address.long_words[0] != target_address)) + continue; + if (!network_game_client_advertised_game_compatible(client, game, FALSE)) + return -1; + if (!game->open) + return -2; + csmemset(¶meters, 0, sizeof(parameters)); + network_game_generate_join_game_token(parameters.join_token); + return network_game_client_initiate_join_game(client, game, ¶meters, &address) ? 1 : -3; + } + return 0; +} + +boolean network_game_client_has_local_player(struct network_game_client *client, short controller_index) +{ + long index; + if (!client || client->machine_index == NONE) + return FALSE; + for (index = 0; index < MAXIMUM_NUMBER_OF_PLAYERS; index++) + { + struct network_player *player = &client->game.players[index]; + if (network_player_is_valid(player) && player->machine_index == client->machine_index && + player->controller_index == controller_index) + return TRUE; + } + return FALSE; +} +#endif + /* ... and puts this machine's players on a team (a team game needs both teams), as the pregame screen's team choice does; NONE: the other team from another machine's player */ @@ -3098,4 +3261,162 @@ boolean network_game_client_set_team( } return success; } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +unsigned long network_game_migration_epoch(void) +{ + return client_migration.epoch; +} +void network_game_client_migration_routes(unsigned long const *addresses, short count) +{ + if (count == HALO_PORT_MAXIMUM_NETWORK_MACHINES) + csmemcpy(client_migration.owner_addresses, addresses, sizeof(client_migration.owner_addresses)); +} + +boolean network_game_client_migration_validate_machines(void const *buffer, long size) +{ + struct network_migration_membership const *membership = buffer; + long index, count = 0; + if (!buffer || size != sizeof(*membership)) + return FALSE; + for (index = 0; index < HALO_PORT_MAXIMUM_NETWORK_MACHINES; index++) + { + short machine_index = membership->machines[index].machine_index; + if (machine_index == NONE) + continue; + if (machine_index != index) + return FALSE; + count++; + } + return membership->machine_count == count; +} + +boolean network_game_client_migration_set_machines(void const *buffer, long size) +{ + struct network_game_client *client = global_network_game_client_get(); + struct network_migration_membership const *membership = buffer; + if (!client || sizeof(client->game.machines) != sizeof(membership->machines) || + !network_game_client_migration_validate_machines(buffer, size)) + return FALSE; + csmemcpy(client->game.machines, membership->machines, sizeof(client->game.machines)); + client->game.machine_count = (short)membership->machine_count; + return TRUE; +} + +unsigned long network_game_client_migration_owner_address(short machine_index) +{ + return machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES ? + client_migration.owner_addresses[machine_index] : 0; +} + +void network_game_client_migration_set_host_machine(short machine_index) +{ + if (machine_index >= 0 && machine_index < MAXIMUM_NETWORK_MACHINE_COUNT) + client_migration.host_machine = machine_index; +} + +short network_game_client_migration_host_machine(struct network_game_client *client) +{ + (void)client; + return client_migration.host_machine; +} + +void network_game_client_migration_remove_machine(struct network_game_client *client, short machine_index) +{ + long index; + if (machine_index < 0 || machine_index >= MAXIMUM_NETWORK_MACHINE_COUNT || machine_index == client->machine_index || + !network_machine_is_valid(&client->game.machines[machine_index])) + return; + for (index = 0; index < MAXIMUM_NUMBER_OF_PLAYERS; index++) + { + struct network_player *player = &client->game.players[index]; + if (network_player_is_valid(player) && player->machine_index == machine_index) + { + long datum_index = unstrip_player_index(player->player_list_index); + struct player_datum *datum = datum_index != NONE ? player_try_and_get(datum_index) : NULL; + if (datum) + { + datum->quit_out_of_game_time = game_time_get() + 1; + /* Score history keeps the datum, but its departed machine no + longer owns input when a fresh player reuses this machine slot. */ + network_player_remove_from_machine(machine_index, datum_index); + } + } + } + network_game_remove_machine(&client->game, &client->game.machines[machine_index]); +} + +boolean network_game_client_begin_migration(struct network_game_client *client, unsigned long target, unsigned long epoch) +{ + if (!client || client->state != _network_game_client_state_ingame || !target || + epoch < client_migration.epoch) + return FALSE; + if (target != IPV4_LOOPBACK_ADDRESS && global_network_game_server_get()) + network_game_demote_migration_host(); + if (client->connect_process) + { + cancel_connect_process(client->connect_process); + client->connect_process = NULL; + } + if (client->connection) + network_connection_delete(client->connection); + client->connection = NULL; + client_migration.reconnecting = TRUE; + client_migration.acknowledged = FALSE; + client_migration.epoch = epoch; + client_migration.target = target; + client_migration.retry_at = system_milliseconds() - 1000UL; + client_migration.attach_at = system_milliseconds() - 500UL; + client->error = _network_game_client_error_none; + client->connection_silent = FALSE; + network_distributed_migration_reset_transport(); + update_queues_migrate(); + network_event("reattaching machine #%d to the same match, epoch #%lu", client->machine_index, epoch); + return TRUE; +} + +boolean network_game_client_migration_waiting(struct network_game_client *client) +{ + return client && client_migration.reconnecting && !client_migration.acknowledged; +} + +boolean network_game_client_migration_ready(struct network_game_client *client) +{ + return client && client_migration.acknowledged; +} + +boolean network_game_client_handle_migration(struct network_game_client *client, void const *message, + word size, struct transport_address *address) +{ + struct network_migration_message ack; + short index; + if (size < 4 || ((byte const *)message)[2] != NETWORK_MIGRATION_MESSAGE) + return FALSE; + if (size != sizeof(ack)) + return TRUE; + csmemcpy(&ack, message, sizeof(ack)); + if (!client_migration.reconnecting || GET_MESSAGE_FLAGS(ack.header) || GET_MESSAGE_TYPE(ack.header) != 2 || + ack.version != NETWORK_MIGRATION_VERSION || ack.kind != NETWORK_MIGRATION_ACK || + ack.epoch != client_migration.epoch || ack.session_seed != client->game.random_seed || + ack.machine_index != client->machine_index || ack.host_machine_index < 0 || + ack.host_machine_index >= MAXIMUM_NETWORK_MACHINE_COUNT || ack.game_tick < 0 || + !network_game_client_address_matches_server(client, address)) + return TRUE; + if (!(ack.machine_present[client->machine_index / 8] & (1 << (client->machine_index % 8))) || + !(ack.machine_present[ack.host_machine_index / 8] & (1 << (ack.host_machine_index % 8))) || + !network_machine_is_valid(&client->game.machines[ack.host_machine_index])) + return TRUE; + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + if (!(ack.machine_present[index / 8] & (1 << (index % 8)))) + network_game_client_migration_remove_machine(client, index); + client_migration.host_machine = ack.host_machine_index; + client_migration.acknowledged = TRUE; + client_migration.reconnecting = FALSE; + game_time_set_distributed(ack.game_tick); + update_queues_migrate(); + client->next_update_number = MAX(ack.game_tick, 1); + network_event("machine #%d preserved its match at tick #%ld with host #%d", client->machine_index, + ack.game_tick, ack.host_machine_index); + return TRUE; +} +#endif diff --git a/source/networking/network_client_manager.h b/source/networking/network_client_manager.h index 3ef12727d5..8d178508e5 100644 --- a/source/networking/network_client_manager.h +++ b/source/networking/network_client_manager.h @@ -10,6 +10,16 @@ header included in hcex build. /* ---------- constants */ +enum network_game_client_state +{ + _network_game_client_state_searching, + _network_game_client_state_joining, + _network_game_client_state_pregame, + _network_game_client_state_ingame, + _network_game_client_state_postgame, + NUMBER_OF_NETWORK_GAME_CLIENT_STATES, +}; + /* ---------- macros */ /* ---------- structures */ @@ -37,6 +47,13 @@ short network_game_client_get_state( short *state_data); boolean network_game_client_join_first_available_game( void); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +/* 0: still searching; 1: joining; -1: incompatible; -2: closed; -3: failed. +The optional address uses transport_address order, as transport_client_start +writes it (IPv4 octets from the most significant to least significant byte). */ +short network_game_client_quick_join(unsigned long target_address); +boolean network_game_client_has_local_player(struct network_game_client *client, short controller_index); +#endif boolean network_game_client_set_team( char team_index); /* whether the advertised game's host has this machine's network version diff --git a/source/networking/network_client_message_handler.c b/source/networking/network_client_message_handler.c index c1fc58cbbc..20cf1ad034 100644 --- a/source/networking/network_client_message_handler.c +++ b/source/networking/network_client_message_handler.c @@ -197,6 +197,9 @@ symbols in this file: #include "game/game_engine.h" #include "game/players.h" #include "networking/network_client_manager.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "network_migration.h" +#endif #include "networking/network_client_message_handler.h" #include "networking/network_game_manager.h" #include "networking/network_messages.h" @@ -229,16 +232,6 @@ enum _message_type_data = 2, }; -enum network_game_client_state -{ - _network_game_client_state_searching, - _network_game_client_state_joining, - _network_game_client_state_pregame, - _network_game_client_state_ingame, - _network_game_client_state_postgame, - NUMBER_OF_NETWORK_GAME_CLIENT_STATES, -}; - enum network_game_packet_class { _network_game_packet_class_advertisement = 1, @@ -449,6 +442,18 @@ static boolean network_game_client_handle_message_server_graceful_game_exit_post /* ---------- globals */ +/* A reconnect at the same address can receive gameplay packets queued for +the previous connection. Until acceptance and loading finish, the new settings +snapshot supplies membership and there is no local simulation to advance. */ +static boolean network_game_client_ignores_early_ingame_messages( + struct network_game_client *client) +{ + short state = network_game_client_get_state(client, NULL); + + return network_game_distributed() && + (state == _network_game_client_state_joining || state == _network_game_client_state_pregame); +} + /* ---------- public code */ boolean network_game_client_handle_message( @@ -475,6 +480,10 @@ boolean network_game_client_handle_message( 0x2F, client && message && (message_size == GET_MESSAGE_SIZE(*message)) && source_address); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (GET_MESSAGE_TYPE(*message) == 2 && network_game_client_handle_migration(client, message, message_size, source_address)) + return TRUE; +#endif message_type = (byte)GET_MESSAGE_TYPE(*message); if (GET_MESSAGE_FLAGS(*message)) { @@ -927,9 +936,12 @@ static boolean network_game_client_receive_game_settings_piece( { boolean result = TRUE; - if (piece->total_size != sizeof(network_game_client_settings_staging) || + /* Protocol 11 exists with and without the 28-byte PC rule extension. + Assemble either complete record, then expand the older layout. */ + if ((piece->total_size != sizeof(network_game_client_settings_staging) && + piece->total_size != sizeof(network_game_client_settings_staging) - sizeof(struct game_variant_options)) || piece->length > sizeof(piece->data) || - piece->offset + piece->length > sizeof(network_game_client_settings_staging)) + piece->offset + piece->length > piece->total_size) { network_event("got a message_server_game_settings_update piece for a different game layout"); network_game_client_settings_staging_size = 0; @@ -956,6 +968,14 @@ static boolean network_game_client_receive_game_settings_piece( network_game_client_settings_staging_size += piece->length; if (network_game_client_settings_staging_size == piece->total_size) { + if(piece->total_size != sizeof(network_game_client_settings_staging)) { + byte *bytes=(byte *)&network_game_client_settings_staging; + csmemmove(bytes+HALO_PORT_NETWORK_GAME_LOCAL_DATA_OFFSET, + bytes+HALO_PORT_NETWORK_GAME_VARIANT_OPTIONS_OFFSET, + sizeof(network_game_client_settings_staging.local_data)); + game_variant_options_default(&network_game_client_settings_staging.variant, + &network_game_client_settings_staging.variant_options); + } network_game_client_settings_staging_size = 0; result = network_game_client_game_settings_updated(client, &network_game_client_settings_staging); if (!result) @@ -1284,6 +1304,11 @@ static boolean network_game_client_handle_message_server_game_update( network_event("failed to decode a message_server_game_update packet"); } } + else if (network_game_client_ignores_early_ingame_messages(client)) + { + network_event("ignoring a message_server_game_update message before loading the game"); + result = TRUE; + } else { network_event("failed to handle a message_server_game_update message; we are not in game"); @@ -1337,6 +1362,11 @@ static boolean network_game_client_handle_message_server_add_player_ingame( network_event("failed to decode a message_server_add_player_ingame packet"); } } + else if (network_game_client_ignores_early_ingame_messages(client)) + { + network_event("ignoring a message_server_add_player_ingame message before loading the game"); + result = TRUE; + } else { network_event("failed to handle a message_server_add_player_ingame message; we are not in game"); @@ -1389,6 +1419,11 @@ static boolean network_game_client_handle_message_server_remove_player_ingame( network_event("failed to decode a message_server_remove_player_ingame packet"); } } + else if (network_game_client_ignores_early_ingame_messages(client)) + { + network_event("ignoring a message_server_remove_player_ingame message before loading the game"); + result = TRUE; + } else { network_event("failed to handle a message_server_remove_player_ingame message; we are not in game"); diff --git a/source/networking/network_connection.c b/source/networking/network_connection.c index 3aeedd616b..91a961988e 100644 --- a/source/networking/network_connection.c +++ b/source/networking/network_connection.c @@ -1624,6 +1624,7 @@ boolean network_connection_idle( 0x21D, connection); +#if !defined(HALO_WEB) && !defined(HALO_IOS_BROWSER) SET_FLAG(connection->flags, _connection_going_stale_bit, FALSE); if (timeout) { @@ -1653,6 +1654,8 @@ boolean network_connection_idle( connection->last_keep_alive_time = current_time; } +#endif + if (TEST_FLAG(connection->flags, _connection_create_server_bit)) { struct network_server_connection *server = (struct network_server_connection *)connection; @@ -1694,6 +1697,40 @@ boolean network_connection_idle( } } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Browser scheduling can pause while healthy RTC packets queue up. */ + if (success) + { + SET_FLAG(connection->flags, _connection_going_stale_bit, FALSE); + if (timeout) + { + if (current_time > connection->last_keep_alive_time + MILLISECONDS_PER_SECOND * 5) + { + SET_FLAG(connection->flags, _connection_going_stale_bit, TRUE); + } + if (current_time > connection->last_keep_alive_time + timeout) + { + if (global_connection_dont_timeout) + { + error( + _error_silent, + "dont timeout is active so not timing out of a connection"); + connection->last_keep_alive_time = current_time; + } + else + { + error(_error_silent, "timeout in network_connection_idle"); + return FALSE; + } + } + } + else + { + connection->last_keep_alive_time = current_time; + } + } +#endif + if (success && connection->unreliable_endpoint) { long free_space = circular_queue_free_space(connection->unreliable_incoming_queue); diff --git a/source/networking/network_game_globals.c b/source/networking/network_game_globals.c index 2e59918c28..c86cce0283 100644 --- a/source/networking/network_game_globals.c +++ b/source/networking/network_game_globals.c @@ -107,6 +107,10 @@ symbols in this file: #include "main/main.h" #include "memory/data_packet_groups.h" #include "network_client_manager.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "network_migration.h" +#include "../../port/linux/game/network_distributed.h" +#endif #include "network_messages.h" #include "network_game_manager.h" #include "network_game_globals.h" @@ -114,16 +118,6 @@ symbols in this file: /* ---------- constants */ -enum network_game_client_state -{ - _network_game_client_state_searching, - _network_game_client_state_joining, - _network_game_client_state_pregame, - _network_game_client_state_ingame, - _network_game_client_state_postgame, - NUMBER_OF_NETWORK_GAME_CLIENT_STATES, -}; - /* ---------- macros */ #define global_network_game_client bss_004566dc.client @@ -300,6 +294,12 @@ boolean network_game_distributed_client( return game_connection() == _game_connection_network_client; } +/* The target branch uses the distributed protocol for every native game. */ +boolean network_game_distributed(void) +{ + return TRUE; +} + boolean network_game_is_active( void) { @@ -592,6 +592,11 @@ boolean network_game_client_end_frame( } else if (network_game_client_get_state(global_network_game_client, NULL) == _network_game_client_state_ingame) { +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* There is no gameplay write until the same player has reattached. */ + if (network_game_client_migration_waiting(global_network_game_client)) + return TRUE; +#endif now = system_milliseconds(); if (now-bss_004566dc.last_client_update_time >= /* (the input goes in its own message, network_distributed.c, @@ -781,3 +786,44 @@ boolean create_global_network_game_server( } /* ---------- private code */ + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +boolean create_global_network_game_server_from_migration(unsigned long epoch) +{ + if (!global_network_game_client || !epoch || !network_distributed_migration_ready()) + return FALSE; + if (global_network_game_server) + { + /* A client outage can elect the current healthy host again. Its + authoritative world is already current; renew only the transport. */ + if (!network_game_server_recover_match(global_network_game_server, epoch)) + return FALSE; + } + else + { + if (!network_distributed_migration_promote()) + return FALSE; + global_network_game_server = network_game_server_adopt_match(global_network_game_client, epoch); + if (!global_network_game_server) + return FALSE; + } + bss_004566dc.client_started = FALSE; + game_connection_set(_game_connection_network_server); + update_queues_migrate(); + return TRUE; +} +#endif + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +void network_game_demote_migration_host(void) +{ + if (global_network_game_server) + { + network_game_server_release_migration_transport(global_network_game_server); + global_network_game_server = NULL; + bss_004566dc.quickstart_local = FALSE; + } + bss_004566dc.client_started = FALSE; + game_connection_set(_game_connection_network_client); +} +#endif diff --git a/source/networking/network_game_globals.h b/source/networking/network_game_globals.h index 9d5737fd18..cd307fba5c 100644 --- a/source/networking/network_game_globals.h +++ b/source/networking/network_game_globals.h @@ -77,6 +77,7 @@ boolean create_global_network_game_server( decides nothing the host does */ boolean network_game_distributed_client( void); +boolean network_game_distributed(void); /* ---------- globals */ diff --git a/source/networking/network_game_ui.c b/source/networking/network_game_ui.c index 87a9fc49e2..36f62d9a9d 100644 --- a/source/networking/network_game_ui.c +++ b/source/networking/network_game_ui.c @@ -10,6 +10,7 @@ symbols in this file: #include "cseries.h" #include "math/real_math.h" +#include "network_game_ui.h" #include "tag_files/tag_groups.h" #include "text/text_group.h" @@ -25,6 +26,25 @@ symbols in this file: /* ---------- public code */ +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +boolean network_game_player_name_is_blank(wchar_t const *name) +{ + short index; + if (!name) return TRUE; + /* Network player names hold eleven UTF-16 units plus NUL. */ + for (index = 0; index < 11 && name[index]; index++) + { + unsigned short character = (unsigned short)name[index]; + if (character > 0x20 && character != 0x85 && character != 0xA0 && character != 0x1680 && + !(character >= 0x2000 && character <= 0x200B) && + character != 0x2028 && character != 0x2029 && character != 0x202F && + character != 0x205F && character != 0x2060 && character != 0x3000 && character != 0xFEFF) + return FALSE; + } + return TRUE; +} +#endif + wchar_t const *network_game_get_random_player_name( void) { @@ -35,7 +55,11 @@ wchar_t const *network_game_get_random_player_name( { struct string_list *string_list = unicode_string_list_definition_get(string_list_index); - if (string_list) + if (string_list +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + && string_list->strings.count > 0 +#endif + ) { short string_index = seed_random_range( get_global_local_random_seed_address(), @@ -45,6 +69,21 @@ wchar_t const *network_game_get_random_player_name( } } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Late joins happen after the UI map (and its random-name tag) unloads. + Use local randomness so naming never changes the gameplay RNG sequence. */ + if (network_game_player_name_is_blank(player_name)) + { + static wchar_t const *const names[] = { + L"Falcon", L"Raven", L"Viper", L"Nova", L"Echo", L"Ghost", L"Blaze", L"Orbit", + L"Cobalt", L"Onyx", L"Ember", L"Atlas", L"Lynx", L"Drake", L"Sable", L"Astra", + L"Bolt", L"Frost", L"Wolf", L"Hawk", L"Noble", L"Dusk", L"Meteor", L"Rogue", + L"Raptor", L"Flint", L"Kestrel", L"Mantis", L"Quartz", L"Talon", L"Phoenix", L"Cipher" + }; + player_name = names[seed_random_range(get_global_local_random_seed_address(), 0, NUMBEROF(names) - 1)]; + } +#endif + return player_name; } diff --git a/source/networking/network_game_ui.h b/source/networking/network_game_ui.h index 31fa452043..112009df54 100644 --- a/source/networking/network_game_ui.h +++ b/source/networking/network_game_ui.h @@ -19,6 +19,10 @@ header included in hcex build. wchar_t const *network_game_get_random_player_name( void); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +boolean network_game_player_name_is_blank(wchar_t const *name); +#endif + /* ---------- globals */ /* ---------- public code */ diff --git a/source/networking/network_migration.h b/source/networking/network_migration.h new file mode 100644 index 0000000000..af8e5812bc --- /dev/null +++ b/source/networking/network_migration.h @@ -0,0 +1,74 @@ +/* Browser host handoff changes transport ownership, never creates a match. */ +#ifndef __NETWORK_MIGRATION_H +#define __NETWORK_MIGRATION_H + +#include "cseries.h" +#include "bungie_net/common/message_header.h" + +struct network_game_client; +struct network_game_server; +struct network_game_server_client_machine; +struct transport_address; + +/* Separate from distributed gameplay message kinds. This handshake is only +accepted on a newly established reliable transport, with its original owner. */ +enum { NETWORK_MIGRATION_MESSAGE = 0xF0, NETWORK_MIGRATION_VERSION = 1, + NETWORK_MIGRATION_ATTACH = 1, NETWORK_MIGRATION_ACK = 2 }; +struct network_migration_message +{ + message_header header; + byte type, version; + unsigned long epoch, session_seed; + short machine_index, host_machine_index, departed_machine_index, kind; + long game_tick; + byte machine_present[(HALO_PORT_MAXIMUM_NETWORK_MACHINES + 7) / 8]; +}; + +/* The in-game player stream does not carry the complete machine roster. +Checkpoint its authoritative membership, including reused and absent slots. */ +struct network_migration_machine +{ + byte name[0x40]; + char machine_index; + byte pad[3]; +}; +struct network_migration_membership +{ + long machine_count; + struct network_migration_machine machines[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; +}; + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +unsigned long network_game_migration_epoch(void); +boolean create_global_network_game_server_from_migration(unsigned long epoch); +boolean network_game_client_begin_migration(struct network_game_client *client, + unsigned long target, unsigned long epoch); +boolean network_game_client_migration_ready(struct network_game_client *client); +boolean network_game_client_migration_waiting(struct network_game_client *client); +boolean network_game_server_migration_ready(struct network_game_server *server); +boolean network_game_server_recover_match(struct network_game_server *server, unsigned long epoch); +void network_game_server_migration_finish(struct network_game_server *server); +struct network_game_server *network_game_server_adopt_match(struct network_game_client *client, + unsigned long epoch); +boolean network_game_client_handle_migration(struct network_game_client *client, + void const *message, word size, struct transport_address *address); +boolean network_game_server_handle_migration(struct network_game_server *server, + struct network_game_server_client_machine *machine, void const *message, word size); +void network_game_server_migration_routes(unsigned long *addresses, short count); +boolean network_game_server_migration_machines(void *buffer, long size); +boolean network_game_client_migration_validate_machines(void const *buffer, long size); +boolean network_game_client_migration_set_machines(void const *buffer, long size); +void network_game_client_migration_routes(unsigned long const *addresses, short count); +unsigned long network_game_client_migration_owner_address(short machine_index); +short network_game_client_migration_host_machine(struct network_game_client *client); +void network_game_client_migration_set_host_machine(short machine_index); +unsigned long web_quick_play_initial_epoch(void); +void network_game_client_migration_remove_machine(struct network_game_client *client, short machine_index); +void update_queues_migrate(void); +void network_game_server_release_migration_transport(struct network_game_server *server); +void network_game_demote_migration_host(void); +boolean web_match_migration_enabled(void); +boolean web_match_migration_lost(void); +unsigned long web_quick_play_address(void); +#endif +#endif diff --git a/source/networking/network_server_manager.c b/source/networking/network_server_manager.c index be9916bb01..a1bdea6fc9 100644 --- a/source/networking/network_server_manager.c +++ b/source/networking/network_server_manager.c @@ -472,6 +472,9 @@ symbols in this file: #include "text/unicode.h" #include "cache/cache_files.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "network_migration.h" +#endif #include "interface/player_ui.h" #include "tag_files/tag_files.h" @@ -768,6 +771,58 @@ static void network_game_server_remove_players_gone_while_loading( struct network_game_server network_game_server_memory_do_not_use_directly; boolean network_game_server_memory_do_not_use_directly_in_use = FALSE; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +static struct +{ + boolean adopting; + unsigned long epoch; + short host_machine, departed_machine; + unsigned long owner_addresses[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + unsigned long disconnected_at[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; +} server_migration; + +void network_game_server_send_player_quit_messages_ingame(struct network_game_server *server, + struct network_game_server_client_machine *machine); +static void network_game_server_migration_detach(struct network_game_server *server, + struct network_game_server_client_machine *machine) +{ + short original = machine->machine_index; + if (original >= 0 && original < MAXIMUM_NETWORK_MACHINE_COUNT) + { + struct transport_address owner = { 0 }; + if (machine->connection) + network_connection_get_address(machine->connection, &owner, NULL); + if (owner.address.long_words[0]) + server_migration.owner_addresses[original] = owner.address.long_words[0] == IPV4_LOOPBACK_ADDRESS ? + web_quick_play_address() : owner.address.long_words[0]; + server_migration.disconnected_at[original] = MAX(system_milliseconds(), 1); + } + if (machine->connection) + network_server_close_client_connection(server->connection, machine->connection); + csmemset(machine, 0, sizeof(*machine)); + machine->machine_index = NONE; +} + +static void network_game_server_migration_expire_disconnected(struct network_game_server *server) +{ + short index; + unsigned long now = system_milliseconds(); + if ((!server_migration.epoch && !web_match_migration_enabled()) || server_migration.adopting) + return; + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + if (server_migration.disconnected_at[index] && now - server_migration.disconnected_at[index] >= 120000UL && + network_machine_is_valid(&server->game.machines[index])) + { + struct network_game_server_client_machine departing = { 0 }; + departing.machine_index = index; + network_game_server_send_player_quit_messages_ingame(server, &departing); + network_game_remove_machine(&server->game, &server->game.machines[index]); + server_migration.disconnected_at[index] = 0; + } + } +} +#endif /* port: the players in the settings the game started with: every machine spawned them, and those whose machines left while the game loaded (whom no quit message reached, the server being in the pregame) are removed from the @@ -1106,6 +1161,9 @@ struct network_game_server *network_game_server_create( 0xE0, !network_game_server_memory_do_not_use_directly_in_use); network_game_server_memory_do_not_use_directly_in_use = TRUE; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + csmemset(&server_migration, 0, sizeof(server_migration)); +#endif csmemset(server, 0, sizeof(*server)); @@ -1353,6 +1411,9 @@ boolean network_game_server_idle( struct network_game_server *server) { boolean success = TRUE; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + network_game_server_migration_expire_disconnected(server); +#endif if (network_game_server_network_lost(server)) { @@ -1880,6 +1941,10 @@ boolean network_game_server_accept_client_machine_into_game( { boolean success = FALSE; long machine_index; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (server_migration.adopting) + return FALSE; +#endif match_assert(NETWORK_SERVER_MANAGER_FILE, 0x3DA, server); match_assert(NETWORK_SERVER_MANAGER_FILE, 0x3DB, machine); @@ -1942,6 +2007,15 @@ boolean network_game_server_accept_client_machine_into_game( &address, FALSE); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (server_migration.epoch) + { + server_migration.disconnected_at[machine_index] = 0; + server_migration.owner_addresses[machine_index] = address.address.long_words[0] == IPV4_LOOPBACK_ADDRESS ? + web_quick_play_address() : address.address.long_words[0]; + } +#endif + network_event( "server added machine @ %s to the game at machine index #%d", transport_address_to_string(&address), @@ -2255,7 +2329,11 @@ boolean network_game_server_add_player_to_game( /* (the name comes from the wire) */ player->name[NETWORK_PLAYER_NAME_LENGTH - 1] = 0; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (network_game_player_name_is_blank(player->name)) +#else if (!player->name[0]) +#endif get_unique_random_name(server, player); if (!player_name_is_unique(server, player->name)) @@ -2919,6 +2997,11 @@ boolean server_has_enough_machines( long machine_count = 0; long client_machine_index; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (network_game_distributed()) + minimum_machine_count = 1; +#endif + for (client_machine_index = 0; client_machine_index < MAXIMUM_NETWORK_MACHINE_COUNT; client_machine_index++) @@ -3177,6 +3260,17 @@ void network_game_server_change_game_variant( return; } +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +boolean network_game_server_enable_quick_play(struct network_game_server *server) +{ + if (!server || server->state != _network_game_server_state_pregame || !network_game_distributed() || + server->game.variant.game_engine_index != game_engine_slayer || server->game.variant.universal_variant.teams) + return FALSE; + server->game.minimum_players = 1; + return network_game_server_send_game_data_pregame(server); +} +#endif + boolean network_game_server_remove_client_machine_from_game( struct network_game_server *server, struct network_game_server_client_machine *client) @@ -3187,6 +3281,18 @@ boolean network_game_server_remove_client_machine_from_game( match_assert(NETWORK_SERVER_MANAGER_FILE, 0x22F, server); match_assert(NETWORK_SERVER_MANAGER_FILE, 0x230, client); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if ((server_migration.epoch || (server->state == _network_game_server_state_ingame && web_match_migration_enabled())) && + !TEST_FLAG(client->flags, _network_client_machine_validated_bit)) + { + if (client->connection) + network_server_close_client_connection(server->connection, client->connection); + csmemset(client, 0, sizeof(*client)); + client->machine_index = NONE; + return TRUE; + } +#endif + if (_network_game_server_state_ingame == server->state) { network_game_server_send_player_quit_messages_ingame(server, client); @@ -3344,6 +3450,20 @@ boolean network_game_server_remove_machine_from_game( match_assert(NETWORK_SERVER_MANAGER_FILE, 0x299, server); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (server_migration.epoch || (server->state == _network_game_server_state_ingame && web_match_migration_enabled())) + { + int pending; + for (pending = 0; pending < MAXIMUM_NETWORK_MACHINE_COUNT; pending++) + { + struct network_game_server_client_machine *candidate = &server->client_machines[pending]; + if (candidate->machine_index == machine->machine_index && candidate->connection && + !TEST_FLAG(candidate->flags, _network_client_machine_validated_bit)) + return network_game_server_remove_client_machine_from_game(server, candidate); + } + } +#endif + if (NONE == machine->machine_index) { network_event( @@ -3586,6 +3706,9 @@ void network_game_server_update_countdown( else { if (network_game_should_accept_remote_connections() == FALSE || +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + (network_game_distributed() && server_has_enough_machines(server)) || +#endif network_game_server_get_client_machine_count(server) > 1) { unsigned long countdown; @@ -3866,6 +3989,13 @@ static boolean network_game_server_setup_game_from_playlist( server->game.name[NETWORK_GAME_NAME_LENGTH - 1] = L'\0'; server->game.map.version = 0; server->game.minimum_players = 2; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* A browser host can play while waiting for peers. Distributed + netcode admits players in progress; lockstep still needs two. The + countdown retains its team and per-machine readiness checks. */ + if (network_game_distributed()) + server->game.minimum_players = 1; +#endif server->game.maximum_players = MAXIMUM_NETWORK_PLAYER_COUNT; network_game_server_port_settings_apply(server); network_game_server_variant_options(&server->game.variant, &server->game.variant_options); @@ -3953,6 +4083,10 @@ static boolean network_game_server_add_new_client( else { server->client_machines[i].connection = new_connection; +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* Reattach transports reserve no new roster/player slot. */ + if (!server_migration.epoch && !(server->state == _network_game_server_state_ingame && web_match_migration_enabled())) +#endif network_game_invalidate_machine(&server->game, i); server->client_machines[i].machine_index = (short)i; server->client_machines[i].flags = @@ -4037,6 +4171,27 @@ static boolean network_game_server_handle_public_endpoint( return success; } +static boolean network_game_server_remove_disconnected_client( + struct network_game_server *server, + struct network_game_server_client_machine *client) +{ +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + /* A quick-play transport can disappear before the first migration epoch + reaches the native host. Retain its roster and owner for reattachment. */ + if (server_migration.epoch || (server->state == _network_game_server_state_ingame && web_match_migration_enabled())) + { + if (TEST_FLAG(client->flags, _network_client_machine_validated_bit)) + { + short machine_index = client->machine_index; + network_game_server_migration_detach(server, client); + network_game_server_client_machine_addresses[machine_index] = 0; + return TRUE; + } + } +#endif + return network_game_server_drop_client_machine(server, client); +} + static boolean network_game_server_handle_client_machines( struct network_game_server *server) { @@ -4058,7 +4213,7 @@ static boolean network_game_server_handle_client_machines( /* (a connection that never joined goes too: it held its slot, and the lobby's start, for ever) */ - if (network_game_server_drop_client_machine(server, client_machine)) + if (network_game_server_remove_disconnected_client(server, client_machine)) { network_event("client machine %x removed from game", machine_index); /* the native builds skip the dump here: it lists every machine @@ -4128,7 +4283,7 @@ static boolean network_game_server_handle_client_machines( short machine_index = client_machine->machine_index; network_event("client machine %x timed out", machine_index); - if (!network_game_server_drop_client_machine(server, client_machine)) + if (!network_game_server_remove_disconnected_client(server, client_machine)) network_event("failed to remove client machine %x from game", machine_index); } /* port: one the distributed netcode found cheating: told, and @@ -4652,3 +4807,297 @@ boolean network_game_server_reset_to_pregame( return success; } + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +struct network_game_server *network_game_server_adopt_match(struct network_game_client *client, unsigned long epoch) +{ + struct network_game_server *server = &network_game_server_memory_do_not_use_directly; + long index; + if (!client || !epoch || network_game_server_memory_do_not_use_directly_in_use || + network_game_client_get_state(client, NULL) != _network_game_client_state_ingame) + return NULL; + csmemset(server, 0, sizeof(*server)); + server->connection = network_connection_new(FLAG(_connection_create_server_bit), NETWORK_GAME_SERVER_PORT); + if (!server->connection) + return NULL; +#ifdef xbox + transport_server_initialize(); +#endif + network_game_server_memory_do_not_use_directly_in_use = TRUE; + csmemset(&server_migration, 0, sizeof(server_migration)); + server_migration.adopting = TRUE; + server_migration.epoch = epoch; + server_migration.host_machine = network_game_client_get_machine_index(client); + server_migration.departed_machine = network_game_client_migration_host_machine(client); + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + server_migration.owner_addresses[index] = network_game_client_migration_owner_address((short)index); + /* A missing host can be partitioned rather than gone. Fence its authority, + retaining its player and recorded owner through the normal rejoin grace. */ + csmemcpy(&server->game, network_game_client_get_game(client), sizeof(server->game)); + server->state = _network_game_server_state_ingame; + server->flags = FLAG(_network_game_server_game_valid_bit); + server->sent_start_game_message = TRUE; + server->next_update_number = MAX(game_time_get(), 1); + server->time_of_last_keep_alive = system_milliseconds(); + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + server->client_machines[index].machine_index = NONE; + network_connection_set_connection_rejection_procedure(server->connection, network_game_server_reject_connection_game_is_full); + /* Promotion bypasses the menu's host setup. A former client must open + the same remote admission gate before its surviving peers reconnect. */ + network_game_accept_remote_connections(TRUE); + network_game_server_open_game(server); + network_event("machine #%d adopted the live match at tick #%ld, epoch #%lu", + server_migration.host_machine, game_time_get(), epoch); + return server; +} + +void network_game_server_migration_routes(unsigned long *addresses, short count) +{ + struct network_game_server *server = global_network_game_server_get(); + short index; + if (!server || count != HALO_PORT_MAXIMUM_NETWORK_MACHINES) + return; + csmemset(addresses, 0, count * sizeof(*addresses)); + if (server_migration.epoch || web_match_migration_enabled()) + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + if (network_machine_is_valid(&server->game.machines[index])) + addresses[index] = server_migration.owner_addresses[index]; + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[index]; + if (machine->connection && TEST_FLAG(machine->flags, _network_client_machine_validated_bit) && + machine->machine_index >= 0 && machine->machine_index < count) + { + struct transport_address address = { 0 }; + network_connection_get_address(machine->connection, &address, NULL); + addresses[machine->machine_index] = address.address.long_words[0] == IPV4_LOOPBACK_ADDRESS ? + web_quick_play_address() : address.address.long_words[0]; + } + } +} + +boolean network_game_server_recover_match(struct network_game_server *server, unsigned long epoch) +{ + struct network_game_client *client = global_network_game_client_get(); + unsigned long owners[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + short index; + if (!server || !client || !epoch || epoch <= server_migration.epoch || + server->state != _network_game_server_state_ingame) + return FALSE; + network_game_server_migration_routes(owners, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + if (server->connection) + network_connection_delete(server->connection); + server->connection = network_connection_new(FLAG(_connection_create_server_bit), NETWORK_GAME_SERVER_PORT); + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + csmemset(&server->client_machines[index], 0, sizeof(server->client_machines[index])); + server->client_machines[index].machine_index = NONE; + } + if (!server->connection) + { + /* Retire only the failed listener, preserving the loaded client and + world while another survivor can take over. */ + network_game_demote_migration_host(); + return FALSE; + } + csmemset(&server_migration, 0, sizeof(server_migration)); + server_migration.adopting = TRUE; + server_migration.epoch = epoch; + server_migration.host_machine = network_game_client_get_machine_index(client); + server_migration.departed_machine = NONE; + csmemcpy(server_migration.owner_addresses, owners, sizeof(owners)); + server->next_update_number = MAX(game_time_get(), 1); + server->time_of_last_keep_alive = system_milliseconds(); + network_connection_set_connection_rejection_procedure(server->connection, network_game_server_reject_connection_game_is_full); + network_game_accept_remote_connections(TRUE); + network_game_server_open_game(server); + network_event("existing host #%d renewed live-match transport at tick #%ld, epoch #%lu", + server_migration.host_machine, game_time_get(), epoch); + return TRUE; +} + +boolean network_game_server_migration_machines(void *buffer, long size) +{ + struct network_game_server *server = global_network_game_server_get(); + struct network_migration_membership *membership = buffer; + if (!server || !buffer || size != sizeof(*membership) || + sizeof(server->game.machines) != sizeof(membership->machines)) + return FALSE; + membership->machine_count = server->game.machine_count; + csmemcpy(membership->machines, server->game.machines, sizeof(membership->machines)); + return network_game_client_migration_validate_machines(buffer, size); +} + +static void network_game_server_migration_acknowledge(struct network_game_server *server) +{ + long index; + struct network_migration_message ack; + csmemset(&ack, 0, sizeof(ack)); + ack.type = NETWORK_MIGRATION_MESSAGE; + ack.version = NETWORK_MIGRATION_VERSION; + ack.epoch = server_migration.epoch; + ack.session_seed = server->game.random_seed; + ack.kind = NETWORK_MIGRATION_ACK; + + ack.host_machine_index = server_migration.host_machine; + ack.departed_machine_index = server_migration.departed_machine; + ack.game_tick = game_time_get(); + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + if (network_machine_is_valid(&server->game.machines[index])) + ack.machine_present[index / 8] |= (byte)(1 << (index % 8)); + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[index]; + if (machine->connection && TEST_FLAG(machine->flags, _network_client_machine_validated_bit)) + { + ack.machine_index = machine->machine_index; + /* network_connection_write swaps this header in place. Rebuild + it for every recipient so all survivors receive valid framing. */ + build_message_header(&ack.header, sizeof(ack), 2, 0); + network_connection_write(machine->connection, &ack, sizeof(ack), NULL, TRUE); + } + } + +} + +boolean network_game_server_handle_migration(struct network_game_server *server, + struct network_game_server_client_machine *machine, void const *message, word size) +{ + struct network_migration_message attach; + struct transport_address address = { 0 }; + long index; + unsigned long expected_address; + if (size < 4 || ((byte const *)message)[2] != NETWORK_MIGRATION_MESSAGE) + return FALSE; + if (size != sizeof(attach)) + return TRUE; + csmemcpy(&attach, message, sizeof(attach)); + if ((!server_migration.epoch && !web_match_migration_enabled()) || + server->state != _network_game_server_state_ingame || GET_MESSAGE_FLAGS(attach.header) || + GET_MESSAGE_TYPE(attach.header) != 2 || attach.version != NETWORK_MIGRATION_VERSION || + attach.kind != NETWORK_MIGRATION_ATTACH || attach.epoch != server_migration.epoch || + attach.session_seed != (unsigned long)server->game.random_seed || attach.machine_index < 0 || + attach.machine_index >= MAXIMUM_NETWORK_MACHINE_COUNT || + !network_machine_is_valid(&server->game.machines[attach.machine_index])) + return TRUE; + if (!server_migration.epoch) + { + /* Initial authority also admits an existing player's repaired stream. + Routes come only from validated connections and retained owner records. */ + unsigned long owners[HALO_PORT_MAXIMUM_NETWORK_MACHINES]; + struct network_game_client *local = global_network_game_client_get(); + if (!local) return TRUE; + network_game_server_migration_routes(owners, HALO_PORT_MAXIMUM_NETWORK_MACHINES); + csmemcpy(server_migration.owner_addresses, owners, sizeof(owners)); + server_migration.host_machine = network_game_client_get_machine_index(local); + server_migration.departed_machine = NONE; + } + network_connection_get_address(machine->connection, &address, NULL); + expected_address = attach.machine_index == server_migration.host_machine ? IPV4_LOOPBACK_ADDRESS : + server_migration.owner_addresses[attach.machine_index]; + if (!expected_address || address.address.long_words[0] != expected_address) + { + network_event("rejected migration reattach: original machine #%d owner address differs", attach.machine_index); + return TRUE; + } + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + struct network_game_server_client_machine *other = &server->client_machines[index]; + if (other != machine && other->connection && other->machine_index == attach.machine_index && + TEST_FLAG(other->flags, _network_client_machine_validated_bit)) + { + /* The same recorded owner may replace a half-open old stream. + Retire its transport, without deleting its machine or player. */ + network_server_close_client_connection(server->connection, other->connection); + csmemset(other, 0, sizeof(*other)); + other->machine_index = NONE; + } + } + machine->machine_index = attach.machine_index; + server_migration.disconnected_at[attach.machine_index] = 0; + machine->flags = FLAG(_network_client_machine_connected_bit) | FLAG(_network_client_machine_validated_bit) | + FLAG(_network_client_machine_level_loaded_bit) | FLAG(_network_client_machine_precached_bit); + machine->last_received_update_sequence_number = MAX(game_time_get(), 1); + /* The final ACK remains replayable when its transport dropped after + the cohort committed; there is still no new join/player allocation. */ + if (!server_migration.adopting) + network_game_server_migration_acknowledge(server); + return TRUE; +} + +boolean network_game_server_migration_ready(struct network_game_server *server) +{ + long index, connection_index; + if (!server || !server_migration.adopting) + return FALSE; + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + boolean attached = FALSE; + if (!network_machine_is_valid(&server->game.machines[index]) || index == server_migration.departed_machine) + continue; + for (connection_index = 0; connection_index < MAXIMUM_NETWORK_MACHINE_COUNT; connection_index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[connection_index]; + if (machine->machine_index == index && machine->connection && + TEST_FLAG(machine->flags, _network_client_machine_validated_bit)) + { + attached = TRUE; + break; + } + } + if (!attached) + return FALSE; + } + return TRUE; +} + +void network_game_server_migration_finish(struct network_game_server *server) +{ + long index, connection_index; + if (!server || !server_migration.adopting) + return; + for (connection_index = 0; connection_index < MAXIMUM_NETWORK_MACHINE_COUNT; connection_index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[connection_index]; + if (machine->connection && !TEST_FLAG(machine->flags, _network_client_machine_validated_bit)) + network_game_server_remove_client_machine_from_game(server, machine); + } + for (index = 0; index < MAXIMUM_NETWORK_MACHINE_COUNT; index++) + { + boolean attached = FALSE; + if (!network_machine_is_valid(&server->game.machines[index])) + continue; + for (connection_index = 0; connection_index < MAXIMUM_NETWORK_MACHINE_COUNT; connection_index++) + { + struct network_game_server_client_machine *machine = &server->client_machines[connection_index]; + if (machine->machine_index == index && machine->connection && + TEST_FLAG(machine->flags, _network_client_machine_validated_bit)) + attached = TRUE; + } + if (!attached) + { + /* Resume reachable players, retaining missing owners for bounded + retries instead of invalidating their identity after 12 seconds. */ + server_migration.disconnected_at[index] = MAX(system_milliseconds(), 1); + } + } + network_game_server_migration_acknowledge(server); + server_migration.adopting = FALSE; +} +#endif + +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +void network_game_server_release_migration_transport(struct network_game_server *server) +{ + if (!server) + return; + if (server->connection) + network_connection_delete(server->connection); +#ifdef xbox + transport_server_terminate(); +#endif + csmemset(server, 0, sizeof(*server)); + csmemset(&server_migration, 0, sizeof(server_migration)); + network_game_server_memory_do_not_use_directly_in_use = FALSE; +} +#endif diff --git a/source/networking/network_server_manager.h b/source/networking/network_server_manager.h index e11c1276cd..ab947f042f 100644 --- a/source/networking/network_server_manager.h +++ b/source/networking/network_server_manager.h @@ -78,6 +78,11 @@ void network_game_server_change_map_name( void network_game_server_change_game_variant( struct network_game_server *server, struct game_variant *variant); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +/* Validate the quick-play Slayer setup. Browser distributed System Link +also allows a solo start and remains open for in-progress joins. */ +boolean network_game_server_enable_quick_play(struct network_game_server *server); +#endif /* ---------- globals */ diff --git a/source/networking/network_server_message_handler.c b/source/networking/network_server_message_handler.c index 259a650a49..f06646f1a3 100644 --- a/source/networking/network_server_message_handler.c +++ b/source/networking/network_server_message_handler.c @@ -260,6 +260,9 @@ symbols in this file: #include "networking/network_game_protocol.h" #include "networking/network_messages.h" #include "networking/network_server_manager_internal.h" +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) +#include "network_migration.h" +#endif #include "networking/network_server_message_handler.h" #include "text/unicode.h" /* system_milliseconds(), for the settings update interval */ @@ -749,6 +752,18 @@ boolean network_game_server_send_game_settings_to_client_machine( struct message_server_game_settings_update message; long offset; +#ifdef HALO_IOS_BROWSER + byte legacy_game[HALO_PORT_NETWORK_GAME_SIZE]; + int host_ios_browser_mode(void); + if(host_ios_browser_mode() && game_size==sizeof(struct network_game)) { + csmemcpy(legacy_game,game,HALO_PORT_NETWORK_GAME_VARIANT_OPTIONS_OFFSET); + csmemcpy(legacy_game+HALO_PORT_NETWORK_GAME_VARIANT_OPTIONS_OFFSET, + (byte const *)game+HALO_PORT_NETWORK_GAME_LOCAL_DATA_OFFSET, + sizeof(((struct network_game *)0)->local_data)); + game=legacy_game;game_size-=sizeof(struct game_variant_options); + } +#endif + for (offset = 0; offset < game_size; offset += sizeof(message.data)) { void *encoded_message; @@ -1024,6 +1039,18 @@ boolean network_game_server_send_game_settings_to_all_machines( /* every piece goes out even if one fails for a machine: the others would otherwise keep the old settings (a machine whose connection failed is closed, and is skipped when the update is sent again) */ +#ifdef HALO_IOS_BROWSER + byte legacy_game[HALO_PORT_NETWORK_GAME_SIZE]; + int host_ios_browser_mode(void); + if(host_ios_browser_mode() && game_size==sizeof(struct network_game)) { + csmemcpy(legacy_game,game,HALO_PORT_NETWORK_GAME_VARIANT_OPTIONS_OFFSET); + csmemcpy(legacy_game+HALO_PORT_NETWORK_GAME_VARIANT_OPTIONS_OFFSET, + (byte const *)game+HALO_PORT_NETWORK_GAME_LOCAL_DATA_OFFSET, + sizeof(((struct network_game *)0)->local_data)); + game=legacy_game;game_size-=sizeof(struct game_variant_options); + } +#endif + for (offset = 0; offset < game_size; offset += sizeof(message.data)) { void *encoded_message; @@ -1120,6 +1147,10 @@ boolean network_game_server_handle_client_message( 0xCF, server && machine && message && (message_buffer_size == GET_MESSAGE_SIZE(*message))); +#if defined(HALO_WEB) || defined(HALO_IOS_BROWSER) + if (GET_MESSAGE_TYPE(*message) == 2 && network_game_server_handle_migration(server, machine, message, message_buffer_size)) + return TRUE; +#endif message_type = (byte)GET_MESSAGE_TYPE(*message); if (GET_MESSAGE_FLAGS(*message)) { diff --git a/source/objects/object_types.c b/source/objects/object_types.c index d7febe322c..823672c7ea 100644 --- a/source/objects/object_types.c +++ b/source/objects/object_types.c @@ -472,11 +472,11 @@ struct object_type_definition *object_type_definitions[NUMBER_OF_OBJECT_TYPES] = struct object_type_definition *first_object_type_definition; /* VC7 otherwise emits this tentative definition as a common symbol. */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static word processed_bsp_flags; -#ifndef HALO_ANDROID +#ifndef HALO_ILP32 #pragma bss_seg() #endif diff --git a/source/rasterizer/rasterizer.c b/source/rasterizer/rasterizer.c index 15ba5dd0fc..dccb105ed2 100644 --- a/source/rasterizer/rasterizer.c +++ b/source/rasterizer/rasterizer.c @@ -393,9 +393,16 @@ void _rasterizer_dynamic_vertices_unlock( long dynamic_vertex_buffer_index); void _rasterizer_dynamic_vertices_delete( long dynamic_vertex_buffer_index); +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void *_rasterizer_decal_vertices_lock( + long cache_index, + long cache_size); +#else void *_rasterizer_decal_vertices_lock( short cache_index, unsigned long cache_size); +#endif long _rasterizer_decal_vertices_new( long size); void _rasterizer_decal_vertices_delete( @@ -559,8 +566,14 @@ void _rasterizer_environment_fog_screen_wind_get_vector( short wind_index, real animation_time, real_vector3d *wind_vector); +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void _rasterizer_environment_fog_screen_begin( + short pass); +#else void _rasterizer_environment_fog_screen_begin( boolean render_fog); +#endif void _rasterizer_screen_flash( void); /* ---------- globals */ @@ -686,12 +699,12 @@ struct rasterizer_debug_options rasterizer_debug_options = { 0 }, /* pad8A[2] */ }; /* No PDB name survives for this target-owned BSS symbol. */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static long bss_004662ec; real_argb_color *global_rasterizer_model_ambient_reflection_tint; -#ifndef HALO_ANDROID +#ifndef HALO_ILP32 #pragma bss_seg() #endif diff --git a/source/rasterizer/xbox/rasterizer_xbox_motion_sensor.c b/source/rasterizer/xbox/rasterizer_xbox_motion_sensor.c index 4c210fc058..486197a00b 100644 --- a/source/rasterizer/xbox/rasterizer_xbox_motion_sensor.c +++ b/source/rasterizer/xbox/rasterizer_xbox_motion_sensor.c @@ -62,26 +62,57 @@ struct bitmap_data *bitmap_group_try_and_get_bitmap( long bitmap_group_index, short bitmap_index); +void *_texture_cache_bitmap_get_hardware_format( + struct bitmap_data *bitmap, + boolean block, + boolean load); + +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void rasterizer_set_target( + short target, + short use_depth_buffer, + unsigned long clear_color, + boolean clear_depth, + boolean clear_stencil); +#else void rasterizer_set_target( word target, boolean use_depth_buffer, boolean clear_color, boolean clear_depth, boolean clear_stencil); +#endif +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void rasterizer_set_target_as_texture( + short stage, + short target, + short filtered); +#else void rasterizer_set_target_as_texture( short stage, long target, boolean filtered); +#endif void rasterizer_set_texture_bitmap_data( short stage, struct bitmap_data const *bitmap); +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void rasterizer_set_vertex_shader_permutation( + short vertex_type, + short permutation, + short one_node); +#else void rasterizer_set_vertex_shader_permutation( short vertex_type, short permutation, boolean one_node); +#endif void rasterizer_set_pixel_shader( struct pixel_shader_definition const *definition); diff --git a/source/rasterizer/xbox/rasterizer_xbox_plasma_energy.c b/source/rasterizer/xbox/rasterizer_xbox_plasma_energy.c index 7e6aed3000..7f8565371d 100644 --- a/source/rasterizer/xbox/rasterizer_xbox_plasma_energy.c +++ b/source/rasterizer/xbox/rasterizer_xbox_plasma_energy.c @@ -118,10 +118,18 @@ void rasterizer_set_texture( long bitmap_definition_index, short bitmap_sequence_index); +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void rasterizer_set_vertex_shader_permutation( + short vertex_type, + short permutation, + short one_node); +#else void rasterizer_set_vertex_shader_permutation( short vertex_type, short permutation, boolean one_node); +#endif void rasterizer_set_pixel_shader( struct pixel_shader_definition const *definition); diff --git a/source/rasterizer/xbox/rasterizer_xbox_text.c b/source/rasterizer/xbox/rasterizer_xbox_text.c index b4da32547b..84e9331a06 100644 --- a/source/rasterizer/xbox/rasterizer_xbox_text.c +++ b/source/rasterizer/xbox/rasterizer_xbox_text.c @@ -121,10 +121,18 @@ void rasterizer_set_texture_bitmap_data( short stage, struct bitmap_data const *bitmap); +#ifdef HALO_WEB +/* the definition's parameter types: a WebAssembly call must match them */ +void rasterizer_set_vertex_shader_permutation( + short vertex_type, + short permutation, + short one_node); +#else void rasterizer_set_vertex_shader_permutation( short vertex_type, short permutation, boolean one_node); +#endif void rasterizer_set_pixel_shader( struct pixel_shader_definition const *pixel_shader_definition); diff --git a/source/sound/sound_manager.c b/source/sound/sound_manager.c index 0a8e573e85..ff23ca7951 100644 --- a/source/sound/sound_manager.c +++ b/source/sound/sound_manager.c @@ -3684,6 +3684,17 @@ void sound_idle( return; } +#ifdef HALO_IOS +/* AppKit tracking can hold the event pump before sound initialization or + during loading. Only enter the streaming service when it is ready. */ +void sound_idle_for_native_menu(void) +{ + if (sound_manager_globals.initialized && sound_manager_globals.enabled && + !sound_manager_globals.idling) + sound_idle(); +} +#endif + static void refresh_listener( void) { diff --git a/source/structures/leaf_map.c b/source/structures/leaf_map.c index 432303ddea..cbe87e2329 100644 --- a/source/structures/leaf_map.c +++ b/source/structures/leaf_map.c @@ -143,6 +143,12 @@ symbols in this file: #include "render/render_debug.h" #include "render/render_debug_geometry.h" +#ifdef HALO_WEB +/* called without a prototype in scope; a WebAssembly call must match the +definition's signature */ +void render_debug_line(boolean immediate, real_point3d const *point0, real_point3d const *point1, real_argb_color const *color); +#endif + /* ---------- constants */ enum diff --git a/source/text/unicode.c b/source/text/unicode.c index 8c61676983..aaa5d5b1b3 100644 --- a/source/text/unicode.c +++ b/source/text/unicode.c @@ -268,11 +268,11 @@ enum /* ---------- globals */ -#ifndef HALO_ANDROID /* Mach-O section names differ; the default is .bss anyway */ +#ifndef HALO_ILP32 /* Mach-O section names differ; the default is .bss anyway */ #pragma bss_seg(".bss") #endif static wchar_t bss_004c1a08[0x100]; -#ifndef HALO_ANDROID +#if !defined(HALO_ANDROID) && !defined(HALO_IOS) #pragma bss_seg() #endif diff --git a/source/units/bipeds.c b/source/units/bipeds.c index a5fb760872..af0d976432 100644 --- a/source/units/bipeds.c +++ b/source/units/bipeds.c @@ -276,6 +276,11 @@ symbols in this file: #include "scenario/scenario.h" #include "structures/structure_bsp_definitions.h" +#ifdef HALO_WEB +/* called without a prototype in scope; a WebAssembly call must match the +definition's signature */ +short unit_get_local_player_index(long unit_index); +#endif /* port: an unarmed player's melee's length, in ticks (a weapon's is about this: its first person melee animation, sped up a quarter) */ #define UNARMED_MELEE_TICKS 16 diff --git a/tools/apple_icon.swift b/tools/apple_icon.swift new file mode 100644 index 0000000000..137dad9d16 --- /dev/null +++ b/tools/apple_icon.swift @@ -0,0 +1,38 @@ +import AppKit +import ImageIO +import UniformTypeIdentifiers +// Draw through CoreGraphics: NSImage drawing into an offscreen AppKit context +// can silently produce black output when this command runs without an app. +let directory = CommandLine.arguments[1] +let sourceURL = URL(fileURLWithPath: "port/apple/RingArtwork.png") +let source = CGImageSourceCreateImageAtIndex(CGImageSourceCreateWithURL(sourceURL as CFURL, nil)!, 0, nil)! +func writeIcon(size:Int, url:URL) { + let context = CGContext(data:nil,width:size,height:size,bitsPerComponent:8,bytesPerRow:size*4, + space:CGColorSpaceCreateDeviceRGB(),bitmapInfo:CGImageAlphaInfo.noneSkipLast.rawValue)! + context.interpolationQuality = .high + context.draw(source,in:CGRect(x:0,y:0,width:size,height:size)) + let rendered = context.makeImage()! + let output = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(output, rendered, nil) + precondition(CGImageDestinationFinalize(output)) +} + +for size in [20,29,40,58,60,76,80,87,120,152,167,180,1024] { + writeIcon(size:size,url:URL(fileURLWithPath:directory+"/AppIcon-\(size).png")) +} +// Optional second output is the macOS .icns file. +if CommandLine.arguments.count > 2 { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let iconset = temporary.appendingPathComponent("Community.iconset") + try FileManager.default.createDirectory(at:iconset,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:temporary) } + for size in [16,32,128,256,512] { + for scale in [1,2] { + let suffix = scale == 2 ? "@2x" : "" + writeIcon(size:size*scale,url:iconset.appendingPathComponent("icon_\(size)x\(size)\(suffix).png")) + } + } + let process = Process();process.executableURL=URL(fileURLWithPath:"/usr/bin/iconutil") + process.arguments=["-c","icns",iconset.path,"-o",CommandLine.arguments[2]] + try process.run();process.waitUntilExit();precondition(process.terminationStatus == 0) +} diff --git a/tools/apple_store_test.py b/tools/apple_store_test.py new file mode 100644 index 0000000000..c8124ce832 --- /dev/null +++ b/tools/apple_store_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Exercise the private image transaction with synthetic Xbox images.""" +import hashlib +from pathlib import Path +import plistlib +import subprocess +import tempfile +import unittest +from ios_xiso_test import fixture, ROOT +PROBE=ROOT/'build/apple-store-probe' +subprocess.run(['xcrun','clang','-fobjc-arc','-Wno-deprecated-declarations','-Wno-incompatible-pointer-types','-Iport/apple','-Iport/runtime','port/apple/game_store_probe.m','port/apple/game_store.m','port/runtime/xiso.c','-framework','Foundation','-o',str(PROBE)],cwd=ROOT,check=True) +class StoreTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.root=Path(self.temp.name);self.store=self.root/'private';self.image=self.root/'game.xiso';fixture(self.image) + def tearDown(self):self.temp.cleanup() + def run_probe(self,mode,success=True): + args=[str(PROBE),mode,str(self.store)]+([] if mode=='ready' else [str(self.image)]) + p=subprocess.run(args,capture_output=True,text=True,timeout=20);self.assertEqual(p.returncode,0 if success else 1,p.stderr) + def generation(self):return self.store/plistlib.loads((self.store/'game-store.plist').read_bytes())['generation'] + def test_retained_verified_copy_and_restart(self): + original=self.image.read_bytes();self.run_probe('import');generation=self.generation();self.assertEqual((generation/'disc.iso').read_bytes(),original) + manifest=plistlib.loads((generation/'integrity.plist').read_bytes());self.assertEqual(manifest['sha256'],hashlib.sha256(original).hexdigest()) + self.image.unlink();self.run_probe('ready') + def test_cancel_preserves_existing_generation(self): + self.run_probe('import');before=self.generation();self.run_probe('cancel',False);self.assertEqual(self.generation(),before);self.assertEqual(len(list(self.store.glob('game-*'))),2) # generation plus pointer + self.run_probe('ready') + def test_corrupt_image_does_not_replace_working_game(self): + self.run_probe('import');before=self.generation();self.image.write_bytes(b'not an xbox image');self.run_probe('import',False);self.assertEqual(self.generation(),before);self.run_probe('ready') + def test_truncated_private_copy_is_rejected(self): + self.run_probe('import');(self.generation()/'disc.iso').write_bytes(b'broken');self.run_probe('ready',False) + def test_recovers_only_owned_unpublished_imports(self): + self.run_probe('import');before=self.generation() + import uuid + abandoned=self.store/('game-'+str(uuid.uuid4()));abandoned.mkdir();(abandoned/'import.pending').write_text('Halo private image transaction v1') + unowned=self.store/('game-'+str(uuid.uuid4()));unowned.mkdir() + self.run_probe('ready');self.assertFalse(abandoned.exists());self.assertTrue(unowned.exists());self.assertEqual(before,self.generation()) + def test_pointer_cannot_escape_storage(self): + self.store.mkdir();(self.store/'game-store.plist').write_bytes(plistlib.dumps({'generation':'../../outside'}));self.run_probe('ready',False) +if __name__=='__main__':unittest.main() diff --git a/tools/browser-relay/.gitignore b/tools/browser-relay/.gitignore new file mode 100644 index 0000000000..713d5006da --- /dev/null +++ b/tools/browser-relay/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/tools/browser-relay/Dockerfile b/tools/browser-relay/Dockerfile new file mode 100644 index 0000000000..145680697e --- /dev/null +++ b/tools/browser-relay/Dockerfile @@ -0,0 +1,25 @@ +# Build from the repository root: +# docker build --platform linux/amd64 -f tools/browser-relay/Dockerfile -t halo-browser-relay . +# The existing native port uses a 32-bit ABI; the JavaScript frontend is 64-bit. +FROM debian:bookworm-slim AS native +RUN apt-get update && apt-get install -y --no-install-recommends gcc-multilib libc6-dev-i386 make \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY port/linux/src/ port/linux/src/ +COPY port/third_party/kcp/ port/third_party/kcp/ +COPY port/relay/ port/relay/ +RUN mkdir -p /out && make -C port/relay OUTPUT=/out/halo-relay-worker + +FROM node:24-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends libc6-i386 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY tools/browser-relay/package.json tools/browser-relay/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund +COPY tools/browser-relay/server.mjs tools/browser-relay/protocol.mjs ./ +COPY --from=native /out/halo-relay-worker /usr/local/bin/halo-relay-worker +ENV NODE_ENV=production RELAY_HOST=0.0.0.0 RELAY_PORT=8789 RELAY_WORKER=/usr/local/bin/halo-relay-worker +USER node +EXPOSE 8789 +HEALTHCHECK --interval=30s --timeout=5s CMD node -e "fetch('http://127.0.0.1:8789/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.mjs"] diff --git a/tools/browser-relay/Dockerfile.dockerignore b/tools/browser-relay/Dockerfile.dockerignore new file mode 100644 index 0000000000..9c6ab23c9d --- /dev/null +++ b/tools/browser-relay/Dockerfile.dockerignore @@ -0,0 +1,21 @@ +# This relay needs source only. Never send extracted game data or local credentials to Docker. +** +!port/ +!port/linux/ +!port/linux/src/ +!port/linux/src/** +!port/third_party/ +!port/third_party/kcp/ +!port/third_party/kcp/** +!port/relay/ +!port/relay/** +port/relay/halo-relay-worker* +port/relay/policy-test +port/relay/__pycache__/ +port/relay/build/ +!tools/ +!tools/browser-relay/ +!tools/browser-relay/package.json +!tools/browser-relay/package-lock.json +!tools/browser-relay/server.mjs +!tools/browser-relay/protocol.mjs diff --git a/tools/browser-relay/README.md b/tools/browser-relay/README.md new file mode 100644 index 0000000000..ce6319ff44 --- /dev/null +++ b/tools/browser-relay/README.md @@ -0,0 +1,141 @@ +# Browser-to-native Halo relay + +This service connects the browser network transport to **one native Halo host +identified by a valid invite**. GitHub Pages serves the game; this separate +process runs on a Linux host with outbound TCP/UDP access. It is not an HTTP +proxy, arbitrary UDP tunnel, game server, or game-data download service. + +The current native game speaks its own encrypted UDP/KCP protocol. A WebSocket +alone cannot talk to it. Each accepted browser session owns a native +`port/relay/halo-relay-worker` process, which joins through the existing native +P2P implementation and only forwards packets to the authenticated invite host. +Browser-only WebRTC rooms remain a separate transport. + +## Local development + +Requirements: Node 22+; Linux x86 with a 32-bit C toolchain for the worker. + +```sh +make -C port/relay +cd tools/browser-relay +npm ci --ignore-scripts +npm test +RELAY_ALLOWED_ORIGINS=http://localhost:8780 npm start +``` + +The default listener is `127.0.0.1:8789`; the WebSocket endpoint is `/join`, +and `GET /health` reports service liveness. The health endpoint does not prove +that a native invite is live or that a multiplayer game has connected. +Localhost may omit the access token. A non-loopback listener refuses startup +without a token of at least 32 bytes, even if it is behind a reverse proxy. + +`npm test` exercises the real WebSocket server using a **mock native worker**: +framing, packet restrictions, authentication, worker lifecycle, connection and +traffic limits, and stalled-worker backpressure. These tests do not establish +native game interoperability; the native worker has its own integration tests. + +For the combined transport test, build `make -C port/relay halo-relay-worker-test` +and run: + +```sh +NATIVE_WORKER="$PWD/port/relay/halo-relay-worker-test" npm --prefix tools/browser-relay run test:native +``` + +That test starts its own local MQTT broker and native echo host, then uses the +real frontend and native worker to check encrypted UDP/discovery, KCP stream +ordering, repeated connections, and cleanup. It never joins a public host. +It validates the transport, **not a Halo multiplayer match**. The test-only +binary permits local broker/candidate addresses and must never be deployed. + +## Container and HTTPS + +Build from the repository root. The 32-bit native ABI requires an amd64 Linux +image, including when building on an ARM computer: + +```sh +docker build --platform linux/amd64 -f tools/browser-relay/Dockerfile -t halo-browser-relay . +``` + +Create a private operator environment file (do not commit it), containing: + +```text +RELAY_ACCESS_TOKEN= +RELAY_ALLOWED_ORIGINS=https://fqlx.github.io,https://abwburns.com +RELAY_MAX_SESSIONS=8 +``` + +Generate the secret with `openssl rand -hex 32`. It is a capability for a small +trusted group, not a public account system. Give it to intended players +separately; **never put it in GitHub Pages source, a Discord invite URL, logs, +or a tracked environment file**. Public access needs a separate short-lived +credential issuer and usage controls; this service does not provide one. + +```sh +docker run --init --read-only --cap-drop=ALL --security-opt=no-new-privileges \ + --pids-limit=64 --memory=512m --tmpfs /tmp:size=16m,noexec,nosuid \ + --env-file /private/path/halo-relay.env \ + -p 127.0.0.1:8789:8789 --name halo-browser-relay halo-browser-relay +``` + +Terminate HTTPS on the operator's reverse proxy. For example, a Caddy site +can forward WebSocket upgrades with: + +```caddyfile +relay.example.com { + reverse_proxy 127.0.0.1:8789 +} +``` + +Configure the browser's trusted site configuration to use +`wss://relay.example.com/join`, and enter the capability in its relay access +field. The browser must not accept an arbitrary relay host from an invite URL. +The relay uses only the real TCP peer address for rate accounting, not client +supplied forwarding headers. A reverse proxy therefore shares a connection +limit across its clients. Extend this deliberately if deploying multiple +trusted proxies. + +No extra host port is needed for the game worker's loopback sockets. Its UDP +tunnel uses outbound traffic and STUN. A VPS with suitable public networking +is preferable; restrictive NAT can still prevent a native host connection. +The WebSocket endpoint must stay private to the intended players and the +operator must keep the Node runtime and `ws` dependency updated. + +## Protocol and boundaries + +1. Connect to `/join` using an allowed exact `Origin`. Tokens are not URL + parameters; unknown paths or any query string are rejected. +2. Send a text frame: + `{"type":"join","invite":"<64 hexadecimal digits>","accessToken":""}`. +3. The worker returns `{"type":"ready","protocol":1,"identifier":"<12 hex>","address":N}`. + The address is the native-generated IPv4 integer in the web engine's byte + representation, inside `100.64.0.0/10`; it is not a browser-selected address. +4. A `peer` event reports the invite host's identifier, virtual address, and + `connected` boolean. The browser enables joining only after connection. +5. Binary messages are the existing `web_packet_header` plus payload, with + four-byte padding. Size/kind/length are little-endian; IPv4 addresses and + ports retain the native packet bytes. Header size is 24 bytes. + +The frontend and worker both validate packet sizes, addresses and ports. +Datagram payloads are at most 1,395 bytes and use game ports 5150/5151. +Stream chunks are at most 16,000 bytes; a stream opens on native port 5150 +from an unprivileged source port. There are at most 16 stream tuples per +session. Only the assigned browser address, authenticated host address, and +game discovery broadcasts are accepted. No browser field chooses a DNS name, +socket destination, process argument, or relay command. + +Default bounds are eight sessions, two MiB/second and 2,000 packets/second +per session (two-second bursts), one MiB queues in either direction, a +10-second initial handshake deadline, a 100-second host connection deadline, +two-minute idle expiry, and a six-hour session lifetime. Both directions apply +backpressure; if either side remains stalled for 15 seconds, the whole session +closes. Reliable stream bytes are never intentionally dropped while keeping +the connection alive. The worker terminates when its WebSocket closes and is +force-killed if necessary. Native stderr is consumed without logging because +the underlying P2P implementation may include invite secrets in diagnostics. + +The worker's stdin/stdout use length-prefixed records: `u32le length`, +`u8 type`, then payload, with length including the type byte and at most +65,536 bytes. Input type 1 is the one initial bare invite; type 2 carries a +web packet. Output type 3 is a JSON event, and type 2 carries a web packet. +Stdout must contain only these records. The relay does not pass its access +token to the worker's arguments or environment. diff --git a/tools/browser-relay/package-lock.json b/tools/browser-relay/package-lock.json new file mode 100644 index 0000000000..4e44b0690c --- /dev/null +++ b/tools/browser-relay/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "halo-browser-relay", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "halo-browser-relay", + "version": "0.1.0", + "dependencies": { + "ws": "8.22.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tools/browser-relay/package.json b/tools/browser-relay/package.json new file mode 100644 index 0000000000..3691c6aeee --- /dev/null +++ b/tools/browser-relay/package.json @@ -0,0 +1,9 @@ +{ + "name": "halo-browser-relay", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { "start": "node server.mjs", "test": "node --test test/*.test.mjs", "test:native": "node test/native-stack.mjs" }, + "dependencies": { "ws": "8.22.0" } +} diff --git a/tools/browser-relay/protocol.mjs b/tools/browser-relay/protocol.mjs new file mode 100644 index 0000000000..12bc30906b --- /dev/null +++ b/tools/browser-relay/protocol.mjs @@ -0,0 +1,114 @@ +/** Native worker framing and the existing web_net.c packet format. */ +export const HEADER_BYTES = 24; +export const MAX_RECORD = 65536; +export const MAX_PACKET = HEADER_BYTES + 16000; +export const KIND = Object.freeze({ DATAGRAM: 1, OPEN: 2, DATA: 3, CLOSE: 4, REFUSE: 5 }); + +export function record(type, payload) { + const bytes = Buffer.from(payload); + if (bytes.length + 1 > MAX_RECORD) throw new Error('Record too large'); + const out = Buffer.allocUnsafe(bytes.length + 5); + out.writeUInt32LE(bytes.length + 1, 0); + out[4] = type; + bytes.copy(out, 5); + return out; +} + +export class RecordDecoder { + #pending = Buffer.alloc(0); + push(chunk, receive) { + this.#pending = Buffer.concat([this.#pending, chunk]); + while (this.#pending.length >= 4) { + const size = this.#pending.readUInt32LE(0); + if (size < 1 || size > MAX_RECORD) throw new Error('Invalid worker record size'); + if (this.#pending.length < size + 4) break; + const type = this.#pending[4]; + const payload = this.#pending.subarray(5, size + 4); + this.#pending = this.#pending.subarray(size + 4); + receive(type, payload); + } + } + get pendingBytes() { return this.#pending.length; } +} + +export function isVirtualAddress(address) { + return Number.isInteger(address) && address > 0 && address <= 0xffffffff && + (address & 255) === 100 && ((address >>> 8) & 255) >= 64 && ((address >>> 8) & 255) < 128; +} + +export function packetHeader(bytes) { + if (bytes.length < HEADER_BYTES || bytes.length > MAX_PACKET || bytes.length % 4 !== 0) + throw new Error('Invalid game packet size'); + const size = bytes.readUInt32LE(0), kind = bytes.readUInt32LE(4), length = bytes.readUInt32LE(20); + if (size !== bytes.length || size !== ((HEADER_BYTES + length + 3) & ~3) || kind < 1 || kind > 5) + throw new Error('Invalid game packet header'); + if ((kind === KIND.DATAGRAM && length > 1395) || (kind === KIND.DATA && (!length || length > 16000)) || + (![KIND.DATAGRAM, KIND.DATA].includes(kind) && length !== 0)) + throw new Error('Invalid game packet payload'); + return { + kind, length, source: bytes.readUInt32LE(8), destination: bytes.readUInt32LE(12), + // The C fields contain network byte order, despite the other fields being little-endian. + sourcePort: bytes.readUInt16BE(16), destinationPort: bytes.readUInt16BE(18), + }; +} + +/** Defense in depth: the worker independently authenticates the native peer. */ +export class SessionPackets { + constructor(address, maxStreams = 16) { + if (!isVirtualAddress(address)) throw new Error('Invalid assigned address'); + this.address = address; + this.peers = new Set(); + this.streams = new Map(); + this.closed = new Map(); + this.maxStreams = maxStreams; + } + peer(address, connected) { + if (!isVirtualAddress(address) || address === this.address) throw new Error('Invalid peer address'); + if (connected) this.peers.add(address); + else { + this.peers.delete(address); + for (const [key, stream] of this.streams) if (stream.peer === address) this.streams.delete(key); + } + } + validate(bytes, outgoing) { + const h = packetHeader(bytes); + const local = outgoing ? h.source : h.destination; + const remote = outgoing ? h.destination : h.source; + const localPort = outgoing ? h.sourcePort : h.destinationPort; + const remotePort = outgoing ? h.destinationPort : h.sourcePort; + const broadcast = h.kind === KIND.DATAGRAM && h.destination === 0xffffffff; + if ((local !== this.address && !(broadcast && !outgoing)) || + (!this.peers.has(remote) && !(broadcast && outgoing))) + throw new Error('Game packet is outside this session'); + if (h.kind === KIND.DATAGRAM) { + if (![5150, 5151].includes(h.sourcePort) || ![5150, 5151].includes(h.destinationPort)) + throw new Error('Unsupported game datagram port'); + return h; + } + if (!localPort || !remotePort) throw new Error('Missing game stream port'); + const key = `${remote}:${localPort}:${remotePort}`; + const now = Date.now(); + for (const [tuple, expires] of this.closed) if (expires <= now) this.closed.delete(tuple); + if (h.kind === KIND.OPEN) { + // Native system link accepts game streams only on 5150. + if (h.destinationPort !== 5150 || h.sourcePort < 1024) + throw new Error('Unsupported game stream port'); + if (this.streams.has(key) || this.closed.has(key)) throw new Error('Game stream tuple reused'); + if (this.streams.size >= this.maxStreams) + throw new Error('Too many game streams'); + this.streams.set(key, { peer: remote }); + } else { + const stream = this.streams.get(key); + // This protocol has full close, not TCP half close. The other side need + // not acknowledge CLOSE; bytes already in flight belong to a dead tuple. + if (!stream && this.closed.has(key)) return { ...h, discard: true }; + if (!stream) throw new Error('Unknown game stream'); + if (h.kind === KIND.REFUSE || h.kind === KIND.CLOSE) { + if (this.closed.size >= 1024) throw new Error('Too many recently closed streams'); + this.streams.delete(key); + this.closed.set(key, now + 30000); + } + } + return h; + } +} diff --git a/tools/browser-relay/server.mjs b/tools/browser-relay/server.mjs new file mode 100644 index 0000000000..d5e87784e4 --- /dev/null +++ b/tools/browser-relay/server.mjs @@ -0,0 +1,282 @@ +import http from 'node:http'; +import { spawn } from 'node:child_process'; +import { timingSafeEqual } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { WebSocketServer, WebSocket } from 'ws'; +import { MAX_PACKET, RecordDecoder, SessionPackets, record, isVirtualAddress } from './protocol.mjs'; + +const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']); +const DEFAULT_WORKER = fileURLToPath(new URL('../../port/relay/halo-relay-worker', import.meta.url)); +const DEFAULTS = { + host: '127.0.0.1', port: 8789, worker: DEFAULT_WORKER, workerArgs: [], + allowedOrigins: ['http://localhost:8780', 'http://127.0.0.1:8780'], accessToken: '', + maxSessions: 8, maxSessionsPerIP: 8, joinTimeoutMs: 10000, peerTimeoutMs: 100000, + idleTimeoutMs: 120000, maxSessionMs: 6 * 60 * 60 * 1000, + maxBufferedBytes: 1024 * 1024, backpressureTimeoutMs: 15000, + bytesPerSecond: 2 * 1024 * 1024, packetsPerSecond: 2000, heartbeatMs: 30000, +}; + +function tokenMatches(value, expected) { + if (!expected) return value === undefined || value === ''; + if (typeof value !== 'string') return false; + const a = Buffer.from(value), b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +export function validateConfig(input = {}) { + const config = { ...DEFAULTS, ...input }; + if (typeof config.host !== 'string' || !Array.isArray(config.allowedOrigins) || !config.allowedOrigins.length) + throw new Error('Configure a bind address and at least one allowed Origin'); + for (const origin of config.allowedOrigins) { + const url = new URL(origin); + if (url.origin !== origin || !['http:', 'https:'].includes(url.protocol) || + (url.protocol !== 'https:' && !LOOPBACK.has(url.hostname))) + throw new Error('Allowed Origins must be exact HTTPS origins (HTTP is allowed on loopback)'); + } + if (typeof config.accessToken !== 'string' || + (!LOOPBACK.has(config.host) && Buffer.byteLength(config.accessToken) < 32)) + throw new Error('Non-loopback binding requires RELAY_ACCESS_TOKEN of at least 32 bytes'); + for (const key of Object.keys(DEFAULTS).filter((key) => typeof DEFAULTS[key] === 'number')) { + if (!Number.isSafeInteger(config[key]) || config[key] < (key === 'port' ? 0 : 1)) + throw new Error(`Invalid ${key}`); + } + if (config.port > 65535) throw new Error('Invalid port'); + return config; +} + +class Budget { + constructor(rate) { this.rate = rate; this.left = rate * 2; this.at = Date.now(); } + take(amount) { + const now = Date.now(); + this.left = Math.min(this.rate * 2, this.left + (now - this.at) * this.rate / 1000); + this.at = now; + if (amount > this.left) return false; + this.left -= amount; + return true; + } +} + +export function createRelay(options = {}) { + const config = validateConfig(options); + const clients = new Set(); + const attempts = new Map(); + let closing = false; + const server = http.createServer((request, response) => { + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + if (request.method === 'GET' && request.url === '/health') { + response.writeHead(closing ? 503 : 200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ service: 'halo-browser-relay', protocol: 1, status: closing ? 'closing' : 'ok' })); + } else { response.writeHead(404); response.end(); } + }); + server.headersTimeout = 10000; + server.requestTimeout = 10000; + const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PACKET, perMessageDeflate: false }); + server.on('upgrade', (request, socket, head) => { + const reject = (status) => { + socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); + }; + const ip = request.socket.remoteAddress || ''; + if (closing || request.method !== 'GET' || request.url !== '/join') return reject('404 Not Found'); + if (!config.allowedOrigins.includes(request.headers.origin)) return reject('403 Forbidden'); + if (clients.size >= config.maxSessions || [...clients].filter((c) => c.ip === ip).length >= config.maxSessionsPerIP) + return reject('503 Service Unavailable'); + const now = Date.now(); + for (const [key, attempt] of attempts) if (now - attempt.at > 60000) attempts.delete(key); + let attempt = attempts.get(ip); + if (!attempt) { + if (attempts.size >= 1024) return reject('429 Too Many Requests'); + attempt = { at: now, count: 0 }; attempts.set(ip, attempt); + } + if (++attempt.count > 20) return reject('429 Too Many Requests'); + wss.handleUpgrade(request, socket, head, (ws) => accept(ws, ip)); + }); + + function accept(ws, ip) { + let worker, packets, joined = false, stopped = false, alive = true, peerConnected = false; + let workerPausedAt = 0, socketPausedAt = 0; + const created = Date.now(); + let lastPacket = created; + const decoder = new RecordDecoder(); + const byteBudget = new Budget(config.bytesPerSecond), packetBudget = new Budget(config.packetsPerSecond); + const client = { ip, stop }; + clients.add(client); + const timer = setInterval(check, Math.min(1000, config.joinTimeoutMs)); + const ping = setInterval(() => { + if (!alive) return stop(1001, 'Connection timed out'); + alive = false; + ws.ping(); + }, config.heartbeatMs); + ws.on('pong', () => { alive = true; }); + ws.on('close', () => stop()); + ws.on('error', () => stop()); + ws.on('message', (data, binary) => { + if (stopped) return; + try { + if (!packetBudget.take(1) || !byteBudget.take(data.length)) throw new Error('Session rate limit'); + if (!joined) { + if (binary || data.length > 1024) throw new Error('Expected join request'); + const request = JSON.parse(data.toString()); + if (!request || request.type !== 'join' || typeof request.invite !== 'string' || + !/^[a-f0-9]{64}$/i.test(request.invite) || + Object.keys(request).some((key) => !['type', 'invite', 'accessToken'].includes(key)) || + !tokenMatches(request.accessToken, config.accessToken)) throw new Error('Invalid join request'); + joined = true; + launchWorker(request.invite.toLowerCase()); + } else { + if (!binary || !packets) throw new Error('Game session is not ready'); + if (packets.validate(data, true).discard) return; + writeWorker(record(2, data)); + lastPacket = Date.now(); + } + } catch { stop(1008, 'Invalid request or session limit'); } + }); + + function check() { + const now = Date.now(); + if (!packets && now - created > config.joinTimeoutMs) return stop(1008, 'Join timed out'); + if (!peerConnected && now - created > config.peerTimeoutMs) return stop(1008, 'Native host did not connect'); + if (now - lastPacket > config.idleTimeoutMs || now - created > config.maxSessionMs) + return stop(1001, 'Session expired'); + if ((workerPausedAt && now - workerPausedAt > config.backpressureTimeoutMs) || + (socketPausedAt && now - socketPausedAt > config.backpressureTimeoutMs)) + return stop(1013, 'Connection too slow'); + } + + function launchWorker(invite) { + worker = spawn(config.worker, config.workerArgs, { + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + // Never give the worker the relay's capability token or operator config. + env: { PATH: process.env.PATH || '/usr/bin:/bin', LANG: 'C', ...(config.workerEnv || {}) }, + }); + // Match p2p_identifier_from_hash: locally administered unicast address. + const hostIdentifier = ((parseInt(invite.slice(0, 2), 16) & 0xfc) | 2) + .toString(16).padStart(2, "0") + invite.slice(2, 12); + worker.on('error', () => stop(1011, 'Native worker unavailable')); + worker.on('exit', () => stop(1011, 'Native worker stopped')); + worker.on('close', () => clients.delete(client)); + worker.stdin.on('error', () => stop(1011, 'Native worker input closed')); + worker.stdin.on('drain', () => { workerPausedAt = 0; if (!stopped) ws.resume(); }); + worker.stdout.on('error', () => stop(1011, 'Native worker output closed')); + worker.stderr.on('error', () => {}); + // Native diagnostics may contain an invite. Consume them without logging. + worker.stderr.resume(); + worker.stdout.on('data', (chunk) => { + if (stopped) return; + try { + decoder.push(chunk, (type, payload) => { + if (stopped) return; + if (type === 2) { + if (!packets) throw new Error('Worker not ready'); + if (packets.validate(payload, false).discard) return; + lastPacket = Date.now(); + send(payload, true); + } else if (type === 3 && payload.length <= 4096) { + const event = JSON.parse(payload.toString()); + if (event.type === 'ready' && !packets && event.protocol === 1 && + /^[a-f0-9]{12}$/i.test(event.identifier) && isVirtualAddress(event.address)) { + packets = new SessionPackets(event.address); + send(JSON.stringify({ type: 'ready', protocol: 1, identifier: event.identifier, address: event.address })); + } else if (event.type === 'peer' && packets && event.identifier === hostIdentifier && + typeof event.connected === 'boolean') { + packets.peer(event.address, event.connected); + peerConnected = event.connected; + send(JSON.stringify({ type: 'peer', identifier: event.identifier, address: event.address, + connected: event.connected })); + } else if (event.type === 'error' || event.type === 'closed') { + stop(1011, 'Native session ended'); + } else throw new Error('Invalid worker event'); + } else throw new Error('Invalid worker record'); + }); + } catch { stop(1011, 'Invalid native session output'); } + }); + writeWorker(record(1, invite)); + } + + function writeWorker(bytes) { + if (!worker || stopped || worker.stdin.destroyed) return; + if (worker.stdin.writableLength + bytes.length > config.maxBufferedBytes) + return stop(1013, 'Native worker is too slow'); + if (!worker.stdin.write(bytes)) { + workerPausedAt ||= Date.now(); + ws.pause(); + } + } + + function send(bytes, binary = false) { + if (stopped || ws.readyState !== WebSocket.OPEN) return; + if (ws.bufferedAmount + Buffer.byteLength(bytes) > config.maxBufferedBytes) + return stop(1013, 'Browser connection is too slow'); + ws.send(bytes, { binary }, (error) => { + if (error) return stop(); + if (!stopped && ws.bufferedAmount < config.maxBufferedBytes / 4 && socketPausedAt) { + socketPausedAt = 0; + worker?.stdout.resume(); + } + }); + if (ws.bufferedAmount > config.maxBufferedBytes / 2 && worker) { + socketPausedAt ||= Date.now(); + worker.stdout.pause(); + } + } + + function stop(code = 1000, reason = 'Session closed') { + if (stopped) return; + stopped = true; + clearInterval(timer); clearInterval(ping); + if (!worker || worker.exitCode !== null || worker.signalCode !== null) clients.delete(client); + if (worker) { + worker.stdin.destroy(); + worker.stdout.destroy(); + worker.kill('SIGTERM'); + const kill = setTimeout(() => { if (worker.exitCode === null && worker.signalCode === null) worker.kill('SIGKILL'); }, 1500); + kill.unref(); + worker.once('exit', () => clearTimeout(kill)); + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + if (code >= 1008 && ws.readyState === WebSocket.OPEN && ws.bufferedAmount < config.maxBufferedBytes / 2) + ws.send(JSON.stringify({ type: 'error', message: reason })); + ws.close(code, reason); + const terminate = setTimeout(() => ws.terminate(), 1000); + terminate.unref(); + ws.once('close', () => clearTimeout(terminate)); + } + } + } + + return { + server, config, + listen: () => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.port, config.host, () => { server.off('error', reject); resolve(server.address()); }); + }), + async close() { + closing = true; + for (const client of clients) client.stop(1001, 'Relay shutting down'); + await new Promise((resolve) => wss.close(resolve)); + await new Promise((resolve) => server.close(resolve)); + server.closeAllConnections(); + }, + get sessions() { return clients.size; }, + }; +} + +export function configFromEnvironment(env = process.env) { + return { + host: env.RELAY_HOST || DEFAULTS.host, + port: Number(env.PORT || env.RELAY_PORT || DEFAULTS.port), + worker: env.RELAY_WORKER || DEFAULTS.worker, + allowedOrigins: env.RELAY_ALLOWED_ORIGINS ? env.RELAY_ALLOWED_ORIGINS.split(',').map((s) => s.trim()) : DEFAULTS.allowedOrigins, + accessToken: env.RELAY_ACCESS_TOKEN || '', + maxSessions: Number(env.RELAY_MAX_SESSIONS || DEFAULTS.maxSessions), + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const relay = createRelay(configFromEnvironment()); + const address = await relay.listen(); + console.log(`Halo browser relay listening on ${address.address}:${address.port}`); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { relay.close().then(() => process.exit(0)); }); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/tools/browser-relay/test/mock-worker.mjs b/tools/browser-relay/test/mock-worker.mjs new file mode 100644 index 0000000000..325ba3ce3f --- /dev/null +++ b/tools/browser-relay/test/mock-worker.mjs @@ -0,0 +1,35 @@ +// Protocol fixture only. This is NOT evidence that native multiplayer works. +import { writeFileSync } from 'node:fs'; +import { RecordDecoder, record } from '../protocol.mjs'; +const mode = process.argv[2] || 'echo'; +if (process.argv[3]) writeFileSync(process.argv[3], String(process.pid)); +const decoder = new RecordDecoder(); +if (mode === 'slow') setInterval(() => {}, 1000); // a deliberately stalled live process +let invited = false; +process.stdin.on('data', (bytes) => { + decoder.push(bytes, (type, data) => { + if (type === 1 && !invited && /^[a-f0-9]{64}$/.test(data.toString())) { + invited = true; + if (mode === 'exit') return process.exit(4); + if (mode === 'silent') return; + const ready = record(3, JSON.stringify({ type: 'ready', protocol: 1, identifier: '112233445566', address: 0x02004064 })); + // Deliberately fragmented header and body exercise framing across pipe reads. + process.stdout.write(ready.subarray(0, 2)); + setTimeout(() => { + process.stdout.write(ready.subarray(2)); + process.stdout.write(record(3, JSON.stringify({ type: 'peer', identifier: ((parseInt(data.toString().slice(0, 2), 16) & 0xfc) | 2).toString(16).padStart(2, '0') + data.toString().slice(2, 12), + address: 0x03004064, connected: true }))); + }, 5); + } else if (type === 2 && invited) { + if (mode === 'malformed') { process.stdout.write(Buffer.from([0xff, 0xff, 0xff, 0xff])); return; } + if (mode === 'slow') { process.stdin.pause(); return; } + const response = Buffer.from(data); + response.writeUInt32LE(0x03004064, 8); + response.writeUInt32LE(0x02004064, 12); + response.writeUInt16BE(data.readUInt16BE(18), 16); + response.writeUInt16BE(data.readUInt16BE(16), 18); + process.stdout.write(record(2, response)); + } else process.exit(5); + }); +}); +process.stdin.on('end', () => process.exit(0)); diff --git a/tools/browser-relay/test/mqtt-fixture.mjs b/tools/browser-relay/test/mqtt-fixture.mjs new file mode 100644 index 0000000000..b24d52a5c0 --- /dev/null +++ b/tools/browser-relay/test/mqtt-fixture.mjs @@ -0,0 +1,67 @@ +// Local MQTT 3.1.1 fixture for native transport tests; never deploy this broker. +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { pathToFileURL } from 'node:url'; + +function mqtt(kind, body) { + const header = [kind]; + let size = body.length; + do { const digit = size % 128; size = Math.floor(size / 128); header.push(digit | (size ? 128 : 0)); } while (size); + return Buffer.concat([Buffer.from(header), body]); +} + +export function broker() { + const sockets = new Set(), topics = new Map(); + const server = net.createServer((socket) => { + sockets.add(socket); + let pending = Buffer.alloc(0); + socket.on('close', () => { sockets.delete(socket); for (const peers of topics.values()) peers.delete(socket); }); + socket.on('error', () => {}); + socket.on('data', (chunk) => { + pending = Buffer.concat([pending, chunk]); + try { + while (pending.length > 1) { + let size = 0, index = 1, multiplier = 1, byte; + do { + if (index >= pending.length) return; + byte = pending[index++]; + size += (byte & 127) * multiplier; multiplier *= 128; + assert.ok(index <= 5 && size < 65536); + } while (byte & 128); + if (pending.length < index + size) return; + const kind = pending[0], body = pending.subarray(index, index + size); + pending = pending.subarray(index + size); + if (kind === 0x10) { + assert.deepEqual(body.subarray(0, 6), Buffer.from([0, 4, 77, 81, 84, 84])); + // Native signalling tries MQTT 5 first. Request its 3.1.1 + // fallback rather than accepting properties we cannot parse. + if (body[6] !== 4) { + socket.end(Buffer.from([0x20, 0x02, 0, 1])); + return; + } + socket.write(Buffer.from([0x20, 0x02, 0, 0])); + } + else if (kind === 0x82) { + const topic = body.subarray(4, 4 + body.readUInt16BE(2)).toString('hex'); + if (!topics.has(topic)) topics.set(topic, new Set()); + topics.get(topic).add(socket); + socket.write(Buffer.from([0x90, 3, body[0], body[1], 0])); + } else if (kind === 0x30) { + const topic = body.subarray(2, 2 + body.readUInt16BE(0)).toString('hex'); + for (const subscriber of topics.get(topic) || []) subscriber.write(mqtt(0x30, body)); + } else if (kind === 0xc0) socket.write(Buffer.from([0xd0, 0])); + } + } catch { socket.destroy(); } + }); + }); + return { server, close: () => { for (const socket of sockets) socket.destroy(); server.close(); } }; +} + + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const fixture = broker(); + fixture.server.listen(Number(process.argv[2] || 18884), '127.0.0.1', () => { + console.log('Local native test MQTT broker ready on ' + fixture.server.address().port); + }); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, fixture.close); +} diff --git a/tools/browser-relay/test/native-stack.mjs b/tools/browser-relay/test/native-stack.mjs new file mode 100644 index 0000000000..cdab6a8d4f --- /dev/null +++ b/tools/browser-relay/test/native-stack.mjs @@ -0,0 +1,148 @@ +/** Real transport fixture: WebSocket -> worker -> encrypted native UDP/KCP. + * This uses a local MQTT broker and native echo host, not a Halo match. + * Run with NATIVE_WORKER= npm run test:native. + */ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { WebSocket } from 'ws'; +import { createRelay } from '../server.mjs'; +import { RecordDecoder } from '../protocol.mjs'; +import { broker } from './mqtt-fixture.mjs'; + +const executable = process.env.NATIVE_WORKER; +if (!executable) throw new Error('Set NATIVE_WORKER to the native test binary (never the production binary).'); +const loader = process.env.NATIVE_LOADER; +const command = loader || executable; +const args = loader ? ['--library-path', process.env.NATIVE_LIBRARY_PATH, executable] : []; + +class Inbox { + items = []; + waiting = []; + error = null; + push(value) { const next = this.waiting.shift(); if (next) next.resolve(value); else this.items.push(value); } + fail(error) { + this.error = error; + for (const waiter of this.waiting.splice(0)) waiter.reject(error); + } + take(context = 'native event') { + if (this.error) return Promise.reject(this.error); + if (this.items.length) return Promise.resolve(this.items.shift()); + return new Promise((resolve, reject) => { + const waiter = { + resolve: (value) => { clearTimeout(timer); resolve(value); }, + reject: (error) => { clearTimeout(timer); reject(error); }, + }; + const timer = setTimeout(() => { + this.waiting.splice(this.waiting.indexOf(waiter), 1); + reject(new Error(`Local native fixture timed out waiting for ${context}`)); + }, 20000); + this.waiting.push(waiter); + }); + } +} + + +function packet(kind, source, destination, sourcePort, destinationPort, payload = Buffer.alloc(0)) { + const bytes = Buffer.alloc((24 + payload.length + 3) & ~3); + bytes.writeUInt32LE(bytes.length, 0); bytes.writeUInt32LE(kind, 4); + bytes.writeUInt32LE(source, 8); bytes.writeUInt32LE(destination, 12); + bytes.writeUInt16BE(sourcePort, 16); bytes.writeUInt16BE(destinationPort, 18); + bytes.writeUInt32LE(payload.length, 20); payload.copy(bytes, 24); + return bytes; +} + +const fixture = broker(); +fixture.server.listen(0, '127.0.0.1'); +await once(fixture.server, 'listening'); +const workerEnv = { HALO_RELAY_TEST_BROKER: `127.0.0.1:${fixture.server.address().port}` }; +const native = spawn(command, [...args, '--echo-host'], { env: { PATH: '/usr/bin:/bin', ...workerEnv }, stdio: ['pipe', 'pipe', 'pipe'] }); +let relay, browser; +const events = new Inbox(), decoder = new RecordDecoder(); +let nativeDiagnostics = ''; +native.stderr.on('data', (data) => { nativeDiagnostics = (nativeDiagnostics + data).slice(-4096); }); +native.stdout.on('data', (bytes) => decoder.push(bytes, (type, data) => { + // The fixture host also reports local stream CLOSE frames as clients leave. + // Those are not its control events and do not need a browser consumer. + assert.ok(type === 2 || type === 3); + if (type === 3) events.push(JSON.parse(data.toString())); +})); +native.on('error', (error) => events.fail(error)); +native.on('exit', (code, signal) => events.fail(new Error(`Native fixture exited (${signal || code})`))); +try { + const hostReady = await events.take(); + assert.equal(hostReady.type, 'ready'); + const invitation = await events.take(); + assert.equal(invitation.type, 'test_invite'); + const invite = invitation.invite.replace('halo://join/', ''); + const accessToken = randomBytes(32).toString('hex'); + relay = createRelay({ host: '127.0.0.1', port: 0, accessToken, + allowedOrigins: ['http://localhost:8780'], worker: command, workerArgs: args, workerEnv }); + const endpoint = await relay.listen(); + browser = new WebSocket(`ws://127.0.0.1:${endpoint.port}/join`, { origin: 'http://localhost:8780' }); + const inbox = new Inbox(); + browser.on('message', (bytes, binary) => inbox.push(binary ? Buffer.from(bytes) : JSON.parse(bytes.toString()))); + browser.on('error', (error) => inbox.fail(error)); + browser.on('close', (code) => inbox.fail(new Error(`Fixture WebSocket closed (${code})`))); + await once(browser, 'open'); + browser.send(JSON.stringify({ type: 'join', invite, accessToken })); + const ready = await inbox.take(), peer = await inbox.take(); + assert.equal(ready.type, 'ready'); assert.equal(peer.type, 'peer'); assert.equal(peer.connected, true); + assert.equal(peer.identifier, hostReady.identifier); + const source = ready.address, destination = peer.address; + for (const address of [destination, 0xffffffff]) { + const data = Buffer.from(Array.from({ length: 256 }, (_, i) => i)); + browser.send(packet(1, source, address, 5151, 5150, data)); + const response = await inbox.take(); + assert.ok(Buffer.isBuffer(response)); + assert.equal(response.readUInt32LE(4), 1); + assert.equal(response.readUInt32LE(8), destination); assert.equal(response.readUInt32LE(12), source); + assert.equal(response.readUInt16BE(16), 5150); assert.equal(response.readUInt16BE(18), 5151); + assert.deepEqual(response.subarray(24, 24 + data.length), data); + } + console.log('PASS: WebSocket frontend -> native encrypted UDP unicast and discovery broadcast'); + for (let connection = 0; connection < 20; connection++) { + const port = 49157 + connection; + // The native fixture echoes its first stream only. Later streams are sinks + // that report bytes/checksum on close, exercising DATA+CLOSE drain without + // waiting for a response before closing the browser side. + const expected = Buffer.from(Array.from({ length: connection === 0 ? 25600 : connection === 1 ? 65536 : 64 }, + (_, i) => (i + connection) & 255)); + browser.send(packet(2, source, destination, port, 5150)); + for (let offset = 0; offset < expected.length; offset += 8000) + browser.send(packet(3, source, destination, port, 5150, expected.subarray(offset, offset + 8000))); + if (connection === 0) { + let received = Buffer.alloc(0); + while (received.length < expected.length) { + const response = await inbox.take(`stream 1 echo (${received.length}/${expected.length} bytes)`); + assert.ok(Buffer.isBuffer(response)); assert.equal(response.readUInt32LE(4), 3); + received = Buffer.concat([received, response.subarray(24, 24 + response.readUInt32LE(20))]); + } + assert.deepEqual(received, expected); + } + browser.send(packet(4, source, destination, port, 5150)); + const closed = await events.take(`native stream ${connection + 1} close receipt`); + let checksum = 0; + for (const byte of expected) checksum = (Math.imul(checksum, 16777619) ^ byte) >>> 0; + assert.deepEqual(closed, { type: 'test_stream_closed', bytes: expected.length, checksum }); + } + console.log('PASS: WebSocket/native KCP echo, immediate CLOSE drains 65,536 bytes, and 20 connect/close cycles'); + const close = once(browser, 'close'); + browser.send(packet(1, source, 0x0100007f, 5151, 5150, Buffer.from('blocked'))); + assert.equal((await close)[0], 1008); + const end = Date.now() + 3000; + while (relay.sessions && Date.now() < end) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(relay.sessions, 0); + console.log('PASS: frontend rejects non-peer destination and terminates its native worker'); + console.log('Transport fixture only: no Halo game or public native host was tested.'); +} finally { + browser?.terminate(); + if (relay) await relay.close(); + if (native.exitCode === null && native.signalCode === null && native.pid) { + const exited = once(native, 'exit'); + native.kill('SIGTERM'); await exited; + } + fixture.close(); + assert.equal(nativeDiagnostics, ''); +} diff --git a/tools/browser-relay/test/relay.test.mjs b/tools/browser-relay/test/relay.test.mjs new file mode 100644 index 0000000000..941efcb9bf --- /dev/null +++ b/tools/browser-relay/test/relay.test.mjs @@ -0,0 +1,215 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocket } from 'ws'; +import { createRelay, validateConfig } from '../server.mjs'; +import { RecordDecoder, SessionPackets, record } from '../protocol.mjs'; + +const ORIGIN = 'https://fqlx.github.io'; +const INVITE = 'abcdef012345' + '01'.repeat(26); +const TOKEN = 'test-only-capability-'.repeat(3); +const MOCK = fileURLToPath(new URL('./mock-worker.mjs', import.meta.url)); +const LOCAL = 0x02004064, PEER = 0x03004064; + +function frame({ kind = 1, source = LOCAL, destination = PEER, sourcePort = 5150, destinationPort = 5150, + data = Buffer.from('game') } = {}) { + const bytes = Buffer.alloc((24 + data.length + 3) & ~3); + bytes.writeUInt32LE(bytes.length, 0); bytes.writeUInt32LE(kind, 4); + bytes.writeUInt32LE(source, 8); bytes.writeUInt32LE(destination, 12); + bytes.writeUInt16BE(sourcePort, 16); bytes.writeUInt16BE(destinationPort, 18); + bytes.writeUInt32LE(data.length, 20); data.copy(bytes, 24); + return bytes; +} + +async function fixture(t, options = {}) { + const dir = await mkdtemp(join(tmpdir(), 'halo-relay-test-')); + const pidFile = join(dir, 'worker.pid'); + const { mode = 'echo', ...overrides } = options; + const relay = createRelay({ host: '127.0.0.1', port: 0, allowedOrigins: [ORIGIN], accessToken: TOKEN, + worker: process.execPath, workerArgs: [MOCK, mode, pidFile], ...overrides }); + const address = await relay.listen(); + t.after(async () => { await relay.close(); await rm(dir, { recursive: true, force: true }); }); + const url = `ws://127.0.0.1:${address.port}/join`; + return { relay, url, pidFile }; +} + +async function connect(url, invite = INVITE, accessToken = TOKEN) { + const ws = new WebSocket(url, { origin: ORIGIN }); + const messages = []; + ws.on('message', (bytes, binary) => messages.push(binary ? Buffer.from(bytes) : JSON.parse(bytes.toString()))); + await once(ws, 'open'); + ws.send(JSON.stringify({ type: 'join', invite, accessToken })); + return { ws, messages }; +} + +async function until(predicate, timeout = 3000) { + const end = Date.now() + timeout; + while (!predicate()) { + if (Date.now() > end) throw new Error('Timed out waiting for test condition'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +test('public binding fails closed without capability; only exact HTTPS origins are accepted', () => { + assert.throws(() => validateConfig({ host: '0.0.0.0' }), /ACCESS_TOKEN/); + assert.throws(() => validateConfig({ allowedOrigins: ['https://example.com/path'] }), /Origins/); + assert.throws(() => validateConfig({ allowedOrigins: ['http://example.com'] }), /Origins/); + assert.throws(() => validateConfig({ allowedOrigins: ['*'] })); + assert.equal(validateConfig({ host: '0.0.0.0', accessToken: TOKEN, allowedOrigins: [ORIGIN] }).accessToken, TOKEN); +}); + +test('record decoder handles split/coalesced records and rejects size bombs', () => { + const decoder = new RecordDecoder(), received = []; + const bytes = Buffer.concat([record(1, INVITE), record(2, 'payload')]); + for (const byte of bytes) decoder.push(Buffer.from([byte]), (type, payload) => received.push([type, payload.toString()])); + assert.deepEqual(received, [[1, INVITE], [2, 'payload']]); + assert.equal(decoder.pendingBytes, 0); + assert.throws(() => decoder.push(Buffer.from([255, 255, 255, 255]), () => {}), /record size/); +}); + +test('packet ACL rejects arbitrary addresses/ports, bad lengths and untracked streams', () => { + const packets = new SessionPackets(LOCAL, 1); + packets.peer(PEER, true); + packets.validate(frame(), true); + packets.validate(frame({ destination: 0xffffffff }), true); + assert.throws(() => packets.validate(frame({ destination: 0x0100007f }), true), /outside/); + assert.throws(() => packets.validate(frame({ source: PEER }), true), /outside/); + assert.throws(() => packets.validate(frame({ destinationPort: 53 }), true), /port/); + assert.throws(() => packets.validate(frame({ data: Buffer.alloc(1396) }), true), /payload/); + assert.throws(() => packets.validate(frame({ kind: 3 }), true), /Unknown/); + const damaged = frame(); damaged.writeUInt32LE(99, 20); + assert.throws(() => packets.validate(damaged, true), /header/); + packets.validate(frame({ kind: 2, sourcePort: 6000, data: Buffer.alloc(0) }), true); + packets.validate(frame({ kind: 3, sourcePort: 6000 }), true); + assert.throws(() => packets.validate(frame({ kind: 2, sourcePort: 6001, data: Buffer.alloc(0) }), true), /Too many/); + packets.validate(frame({ kind: 4, sourcePort: 6000, data: Buffer.alloc(0) }), true); + assert.equal(packets.validate(frame({ kind: 3, sourcePort: 6000 }), true).discard, true); + assert.throws(() => packets.validate(frame({ kind: 2, sourcePort: 6000, data: Buffer.alloc(0) }), true), /reused/); + packets.validate(frame({ kind: 4, source: PEER, destination: LOCAL, destinationPort: 6000, data: Buffer.alloc(0) }), false); + assert.equal(packets.streams.size, 0); +}); + +test('one-sided CLOSE releases stream slots across repeated reconnects', () => { + const packets = new SessionPackets(LOCAL); + packets.peer(PEER, true); + for (let n = 0; n < 100; n++) { + const port = 6000 + n; + packets.validate(frame({ kind: 2, sourcePort: port, data: Buffer.alloc(0) }), true); + const closing = frame({ kind: 4, source: PEER, destination: LOCAL, + destinationPort: port, data: Buffer.alloc(0) }); + packets.validate(closing, false); + assert.equal(packets.streams.size, 0); + assert.equal(packets.validate(closing, false).discard, true); + assert.equal(packets.validate(frame({ kind: 3, sourcePort: port }), true).discard, true); + } + assert.equal(packets.closed.size, 100); +}); + +test('health is read-only and WebSocket upgrade rejects wrong origin/path', async (t) => { + const { url, relay, pidFile } = await fixture(t); + const health = await fetch(url.replace('ws:', 'http:').replace('/join', '/health')); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { service: 'halo-browser-relay', protocol: 1, status: 'ok' }); + for (const [target, origin, expected] of [[url, 'https://attacker.example', 403], [url + '?token=secret', ORIGIN, 404]]) { + const client = new WebSocket(target, { origin }); + const response = await new Promise((resolve) => client.on('unexpected-response', (_, result) => { + result.resume(); resolve(result.statusCode); client.terminate(); + }).on('error', () => {})); + assert.equal(response, expected); + } + assert.equal(relay.sessions, 0); + await assert.rejects(readFile(pidFile), { code: 'ENOENT' }); +}); + +test('invalid invite or capability closes before spawning native worker', async (t) => { + const { url, pidFile } = await fixture(t); + for (const [invite, token] of [['not-an-invite', TOKEN], [INVITE, 'wrong']]) { + const { ws } = await connect(url, invite, token); + const [code] = await once(ws, 'close'); + assert.equal(code, 1008); + } + await assert.rejects(readFile(pidFile), { code: 'ENOENT' }); +}); + +test('mock worker handshake, framed datagram roundtrip and disconnect cleanup', async (t) => { + const { url, relay, pidFile } = await fixture(t); + const { ws, messages } = await connect(url); + await until(() => messages.some((m) => m.type === 'peer')); + assert.deepEqual(messages[0], { type: 'ready', protocol: 1, identifier: '112233445566', address: LOCAL }); + assert.equal(messages[1].identifier, 'aacdef012345'); + ws.send(frame()); + await until(() => messages.some(Buffer.isBuffer)); + const reply = messages.find(Buffer.isBuffer); + assert.equal(reply.readUInt32LE(8), PEER); + assert.equal(reply.subarray(24).toString(), 'game'); + const pid = Number(await readFile(pidFile, 'utf8')); + const closed = once(ws, 'close'); ws.close(); await closed; + await until(() => relay.sessions === 0); + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); +}); + +test('worker crash and malformed output close client with bounded errors', async (t) => { + for (const mode of ['exit', 'malformed']) { + await t.test(mode, async (t) => { + const { url } = await fixture(t, { mode }); + const { ws, messages } = await connect(url); + const closed = once(ws, 'close'); + if (mode === 'malformed') { + await until(() => messages.some((m) => m.type === 'peer')); + ws.send(frame()); + } + assert.equal((await closed)[0], 1011); + assert.ok(messages.some((m) => m.type === 'error')); + assert.ok(!JSON.stringify(messages).includes(INVITE)); + }); + } +}); + +test('session cap rejects additional sockets; invalid frame cannot reach worker', async (t) => { + const { url } = await fixture(t, { maxSessions: 1 }); + const { ws, messages } = await connect(url); + await until(() => messages.some((m) => m.type === 'peer')); + const second = new WebSocket(url, { origin: ORIGIN }); + const status = await new Promise((resolve) => second.on('unexpected-response', (_, result) => { + result.resume(); resolve(result.statusCode); second.terminate(); + }).on('error', () => {})); + assert.equal(status, 503); + const closed = once(ws, 'close'); + ws.send(frame({ destination: 0x08080808, destinationPort: 53 })); + assert.equal((await closed)[0], 1008); + assert.equal(messages.filter(Buffer.isBuffer).length, 0); +}); + +test('silent worker startup and incoming packet flood are bounded', async (t) => { + await t.test('startup timeout', async (t) => { + const { url, relay } = await fixture(t, { mode: 'silent', joinTimeoutMs: 30 }); + const { ws } = await connect(url); + assert.equal((await once(ws, 'close'))[0], 1008); + await until(() => relay.sessions === 0); + }); + await t.test('rate limit', async (t) => { + const { url } = await fixture(t, { packetsPerSecond: 2 }); + const { ws, messages } = await connect(url); + await until(() => messages.some((m) => m.type === 'peer')); + const closed = once(ws, 'close'); + for (let i = 0; i < 10; i++) ws.send(frame()); + assert.equal((await closed)[0], 1008); + }); +}); + +test('stalled worker input is paused and the session closes instead of silently losing stream bytes', async (t) => { + const { url, relay } = await fixture(t, { mode: 'slow', joinTimeoutMs: 100, + backpressureTimeoutMs: 60, packetsPerSecond: 100000, bytesPerSecond: 100000000 }); + const { ws, messages } = await connect(url); + await until(() => messages.some((m) => m.type === 'peer')); + const closed = once(ws, 'close'); + ws.send(frame({ kind: 2, sourcePort: 6000, data: Buffer.alloc(0) })); + const payload = frame({ kind: 3, sourcePort: 6000, data: Buffer.alloc(16000) }); + for (let index = 0; index < 200; index++) ws.send(payload); + assert.equal((await closed)[0], 1013); + await until(() => relay.sessions === 0); +}); diff --git a/tools/build_multiplayer_pages.py b/tools/build_multiplayer_pages.py new file mode 100644 index 0000000000..fd6a654ea7 --- /dev/null +++ b/tools/build_multiplayer_pages.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Package the source-built browser runtime for the game site and its mirror.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[1] + + +def relay_url(value): + if not value: + return value + parsed = urlsplit(value) + if (parsed.scheme != 'wss' and not (parsed.scheme == 'ws' and parsed.hostname in ('localhost', '127.0.0.1', '::1')) + or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment + or parsed.path != '/join'): + raise argparse.ArgumentTypeError('Use wss://relay-host/join (ws://loopback/join for local testing).') + return value + + +def room_code(value): + value = value.strip().upper() + if value and not re.fullmatch(r'[A-Z0-9]{4,16}', value): + raise argparse.ArgumentTypeError('Use 4–16 letters or digits, or an empty value to disable the default room.') + return value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--relay', type=relay_url, default='', help='Native multiplayer WSS endpoint; blank leaves desktop joining unavailable') + parser.add_argument('--default-room', type=room_code, default='FQLX01', help='Shared browser room joined on a first visit; empty disables automatic room entry') + args = parser.parse_args() + native_version = re.search(r'^#define HALO_PORT_NETWORK_VERSION (\d+)$', + (ROOT / 'port/linux/include/halo_port_limits.h').read_text(), re.MULTILINE) + if not native_version: + parser.error('Missing native network version in halo_port_limits.h.') + source = ROOT / 'build/web/site' + output = ROOT / 'dist/browser-multiplayer' + names = [path.relative_to(ROOT / 'port/web/site') for path in (ROOT / 'port/web/site').rglob('*') if path.is_file()] + names += [Path('halo.js'), Path('halo.wasm')] + for name in names: + if not (source / name).is_file(): + parser.error(f'Missing {name}; run python3 configure.py --release and ninja web with Emscripten 6.0.10 first.') + expected = set(names) | {Path('version.json'), Path('deployment.json'), Path('.nojekyll')} + if output.exists(): + unexpected = {path.relative_to(output) for path in output.rglob('*') if path.is_file()} - expected + if unexpected: + parser.error(f'Refusing to overwrite unexpected files in {output}: {sorted(map(str, unexpected))}') + output.mkdir(parents=True, exist_ok=True) + for name in names: + (output / name).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / name, output / name) + (output / 'site-config.js').write_text('window.HALO_BROWSER_CONFIG = Object.freeze(' + json.dumps({'relayUrl': args.relay, 'defaultRoom': args.default_room}) + ');\n') + # Connection configuration participates in the offline cache version. + subprocess.run(['python3', str(ROOT / 'port/web/stamp_version.py'), str(output / 'version.json'), + *map(str, sorted(path for path in output.rglob('*') if path.is_file() and path.name not in {'version.json', 'deployment.json', '.nojekyll'}))], check=True) + (output / '.nojekyll').touch() + receipt = { + 'source_commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), + 'source_dirty': bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True).strip()), + 'runtime': 'source-built Emscripten 6.0.10', + 'native_network_version': int(native_version.group(1)), + 'relay_configured': bool(args.relay), + 'default_browser_room': args.default_room, + 'game_assets_included': False, + 'files': {str(path.relative_to(output)): {'bytes': path.stat().st_size, + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} + for path in sorted(output.rglob('*')) if path.is_file() and path.name != 'deployment.json'}, + } + (output / 'deployment.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(output) + + +if __name__ == '__main__': + main() diff --git a/tools/build_pages.py b/tools/build_pages.py new file mode 100644 index 0000000000..b803d59238 --- /dev/null +++ b/tools/build_pages.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Package the verified browser runtime for a static host, without game data.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + +import setup_browser + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "dist/github-pages" + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-repository", help="Optional public source repository URL for the deployment receipt") + args = parser.parse_args() + # Never copy build/web wholesale: it can contain local experiments. + expected = set(setup_browser.FILES) | set(setup_browser.LOCAL_SCRIPTS) | { + "download-provenance.json", ".nojekyll", "deployment.json", + } + OUTPUT.mkdir(parents=True, exist_ok=True) + unexpected = {path.name for path in OUTPUT.iterdir()} - expected + if unexpected: + raise ValueError(f"Unexpected files in {OUTPUT}: {sorted(unexpected)}") + with tempfile.TemporaryDirectory(prefix="halo-pages-") as temporary: + staging = Path(temporary) + setup_browser.main(staging) + index = staging / "index.html" + html = index.read_text().replace("Halo", "Play Halo CE") + html = html.replace("Local copy of", "Self-hosted copy of") + # All runtime URLs stay relative so /halo-ce-universal/ works. + index.write_text(html) + receipt_path = staging / "download-provenance.json" + receipt = json.loads(receipt_path.read_text()) + receipt["local_index_sha256"] = setup_browser.digest(index.read_bytes()) + receipt["local_changes"].append("Static hosting title and attribution") + receipt_path.write_text(json.dumps(receipt, indent=2) + "\n") + (staging / ".nojekyll").touch() + deployment = { + "source_commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "game_data_included": False, + "files": {path.name: {"bytes": path.stat().st_size, + "sha256": setup_browser.digest(path.read_bytes())} + for path in sorted(staging.iterdir())}, + } + if args.source_repository: + deployment["source_repository"] = args.source_repository + (staging / "deployment.json").write_text(json.dumps(deployment, indent=2) + "\n") + assert {path.name for path in staging.iterdir()} == expected + for path in staging.iterdir(): + shutil.copyfile(path, OUTPUT / path.name) + total = sum(path.stat().st_size for path in OUTPUT.iterdir()) + print(f"Static site: {OUTPUT} ({total:,} bytes; no maps or disc images)") + + +if __name__ == "__main__": + main() diff --git a/tools/embed_assets.py b/tools/embed_assets.py index 81941df6d4..378bd08564 100644 --- a/tools/embed_assets.py +++ b/tools/embed_assets.py @@ -7,6 +7,9 @@ python tools/embed_assets.py OUTPUT.c +--menus-only embeds the XML menus and their redrawn art without HUD/title/font +replacements (the Apple distribution builds). + writes OUTPUT.c with each PNG and the bitmap it stands for (its tag, index and the checksum of its pixels, from port/assets/hud/layout.json and port/assets/titles/titles.json), as @@ -119,8 +122,11 @@ def png_size(data: bytes, name: str) -> tuple: def main() -> None: + menus_only = "--menus-only" in sys.argv + if menus_only: + sys.argv.remove("--menus-only") if len(sys.argv) != 2: - sys.exit("usage: embed_assets.py OUTPUT.c") + sys.exit("usage: embed_assets.py [--menus-only] OUTPUT.c") lines = [ "/* generated by tools/embed_assets.py from port/assets: do not edit */", "", @@ -128,7 +134,7 @@ def main() -> None: "", ] table = [] - for index, (folder, asset, title) in enumerate(textures()): + for index, (folder, asset, title) in enumerate([] if menus_only else textures()): name = f"{asset['name']}.png" data = (ROOT / folder / name).read_bytes() width, height = png_size(data, name) @@ -145,14 +151,14 @@ def main() -> None: f'{int(title)}, asset{index}, {len(data)} }},') lines.append("const struct hud_hires_embedded hud_hires_embedded[] =") lines.append("{") - lines.extend(table) + lines.extend(table or ["\t{ 0 },"]) lines.append("};") lines.append(f"const unsigned int hud_hires_embedded_count = {len(table)};") lines.append("") # the fonts, and which draws each font tag (text_hires.h) lines.append('#include "text_hires.h"') lines.append("") - files = font_files() + files = [] if menus_only else font_files() for index, name in enumerate(files): lines.append(f"static const unsigned int font{index}[] = {{") lines.extend(words((ROOT / FONT_ASSETS / name).read_bytes())) diff --git a/tools/guest_abi_check.py b/tools/guest_abi_check.py new file mode 100644 index 0000000000..651715afda --- /dev/null +++ b/tools/guest_abi_check.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Find calls that only work under the x86 calling convention. + +The decompiled sources sometimes declare a function differently from its +definition (a local prototype in another file, or no prototype at all). +Under 32-bit x86 every argument is a stack slot, so a `long` holding a +float's bits, or an unprototyped call to a variadic function, still works. +The iOS guest (port/ios/README.md) is AArch64 code with Darwin's +calling convention, where floats travel in floating-point registers and +variadic arguments on the stack, so such calls pass garbage. + +Compile the sources to LLVM IR with the guest's flags (-S -emit-llvm) and +run this on the .ll files: it compares every external declaration with the +definition and reports the ones whose register classes or variadic-ness +differ. Differences in return type (void/int) or argument count are +reported only with --all, as they are harmless on AArch64. + +Usage: guest_abi_check.py [--all] file.ll... +""" + +import collections +import re +import sys + +ATTRIBUTES = re.compile( + r"\b(noundef|signext|zeroext|nocapture|readonly|writeonly|nonnull|" + r"dereferenceable(_or_null)?\(\d+\)|align \d+|noalias|returned|nofree|inreg|" + r"byval\([^)]*\)|sret\([^)]*\)|captures\([^)]*\)|initializes\([^)]*\)|" + r"range\([^)]*\)|nofpclass\([^)]*\)|dead_on_unwind|writable)\b") +LINKAGE = re.compile( + r"\b(dso_local|internal|private|weak|linkonce_odr|hidden|available_externally|" + r"extern_weak|protected|local_unnamed_addr|unnamed_addr|common)\b") +FUNCTION = re.compile(r"^(declare|define)\s+(.*?)@(\"?[\w.$]+\"?)\((.*)\)(.*)$") + + +def split_parameters(text): + parts, depth, current = [], 0, "" + for character in text: + if character in "({[<": + depth += 1 + elif character in ")}]>": + depth -= 1 + if character == "," and depth == 0: + parts.append(current) + current = "" + else: + current += character + if current.strip(): + parts.append(current) + return parts + + +def register_class(type_text): + if type_text == "...": + return "..." + if type_text in ("float", "half"): + return "f" + if type_text == "double": + return "d" + if type_text == "i64": + return "l" + if type_text.startswith("i") or type_text == "ptr": + return "i" + return type_text + + +def signature(return_text, parameters): + classes = [] + for parameter in split_parameters(parameters): + parameter = ATTRIBUTES.sub("", parameter).strip() + classes.append(register_class(parameter.split()[0] if parameter else "")) + words = ATTRIBUTES.sub("", LINKAGE.sub("", return_text)).split() + returned = words[-1] if words else "void" + return register_class(returned) if returned != "void" else "v", tuple(classes) + + +def main(): + arguments = sys.argv[1:] + show_all = "--all" in arguments + files = [a for a in arguments if a != "--all"] + declarations = collections.defaultdict(set) + definitions = {} + for path in files: + with open(path, errors="replace") as f: + for line in f: + if not line.startswith(("declare", "define")): + continue + match = FUNCTION.match(line.rstrip()) + if not match: + continue + kind, prefix, name, parameters, _ = match.groups() + if kind == "define": + if re.search(r"\b(internal|private)\b", prefix): + continue + definitions[name] = (signature(prefix, parameters), path) + else: + declarations[name].add((signature(prefix, parameters), path)) + dangerous = 0 + for name in sorted(declarations): + if name not in definitions: + continue + (defined_return, defined_parameters), defined_in = definitions[name] + for (declared_return, declared_parameters), declared_in in sorted(declarations[name]): + if (declared_return, declared_parameters) == (defined_return, defined_parameters): + continue + floats = ("f", "d", "...") + unprototyped = declared_parameters == ("...",) + if unprototyped: + risky = any(c in floats for c in defined_parameters) or defined_return in ("f", "d") + else: + shared = min(len(declared_parameters), len(defined_parameters)) + risky = any( + (a in floats or b in floats) and a != b + for a, b in zip(declared_parameters[:shared], defined_parameters[:shared])) + risky |= (declared_return in ("f", "d")) != (defined_return in ("f", "d")) and \ + "v" not in (declared_return, defined_return) + if risky: + dangerous += 1 + if risky or show_all: + print(f"{'UNSAFE' if risky else 'benign'} {name}") + print(f" defined {defined_return} {defined_parameters} in {defined_in}") + print(f" declared {declared_return} {declared_parameters} in {declared_in}") + print(f"{dangerous} unsafe declaration(s)") + sys.exit(1 if dangerous else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/guest_asm_convert.py b/tools/guest_asm_convert.py new file mode 100644 index 0000000000..809890c22f --- /dev/null +++ b/tools/guest_asm_convert.py @@ -0,0 +1,265 @@ +"""Convert arm64_32 (Apple ILP32) assembly into AArch64 ELF assembly. + +The iOS port runs the game as ILP32 code: native AArch64 instructions +with 32-bit pointers, so the game's data formats (which embed 32-bit +pointers, exactly as on the Xbox) keep their layout. Clang only offers such +a target as ``arm64_32-apple-watchos``, which emits Mach-O. The generated +*code* is ordinary AArch64 though; only the assembly syntax, section names, +symbol decoration and relocation operators are Darwin-specific. This script +rewrites them for the ELF assembler, so the objects can be linked by ld.lld +into a guest image. The iOS instrumentation pass lifts its memory accesses +into an aligned native arena before embedding it in the app. + +Usage: guest_asm_convert.py input.s output.s +""" + +import re +import sys + +IDENT = re.compile(r'"[^"]*"|[A-Za-z_.$][\w.$]*') + +DROP_DIRECTIVES = { + ".subsections_via_symbols", ".build_version", ".watchos_version_min", + ".ios_version_min", ".macosx_version_min", ".tvos_version_min", ".loh", + ".data_region", ".end_data_region", ".linker_option", ".indirect_symbol", + ".addrsig", ".addrsig_sym", ".no_dead_strip", ".alt_entry", ".ident", + ".desc", ".cfi_startproc", ".cfi_endproc", ".cfi_def_cfa", + ".cfi_offset", ".cfi_def_cfa_offset", ".cfi_def_cfa_register", + ".cfi_personality", ".cfi_lsda", ".cfi_remember_state", + ".cfi_restore_state", ".cfi_restore", ".cfi_adjust_cfa_offset", + ".cfi_same_value", ".cfi_escape", +} + +RODATA_SECTIONS = { + "__cstring", "__const", "__literal4", "__literal8", "__literal16", + "__ustring", +} + + +class ConvertError(Exception): + pass + + +def strip_comment(line: str) -> str: + out = [] + in_string = False + i = 0 + while i < len(line): + c = line[i] + if in_string: + out.append(c) + if c == "\\" and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == '"': + in_string = False + else: + if c == '"': + in_string = True + elif c == ";" or line.startswith("//", i): + break + out.append(c) + i += 1 + return "".join(out).rstrip() + + +class Converter: + def __init__(self, lines): + self.lines = lines + self.local_labels = set() + self.out = [] + self.in_dropped_section = False + for line in lines: + text = strip_comment(line).strip() + m = re.match(r'^([A-Za-z_.$][\w.$]*|"[^"]*"):', text) + if m and m.group(1)[0] in "lL": + self.local_labels.add(m.group(1)) + + def sym(self, name: str) -> str: + if name.startswith('"'): + inner = name[1:-1] + if inner.startswith("_"): + inner = inner[1:] + return '"' + inner + '"' + if name.startswith("_"): + return name[1:] + if name in self.local_labels: + return ".L" + name + return name + + def operands(self, text: str) -> str: + # relocation operators first + text = re.sub(r'([\w.$"]+)@GOTPAGE\b', r"\1", text) + text = re.sub(r'([\w.$"]+)@PAGE\b', r"\1", text) + text = re.sub(r'(\([^)]*\)|[\w.$"]+(?:[+-]\d+)?)@PAGEOFF\b', r":lo12:\1", text) + if "@" in text and not re.search(r'"[^"]*@[^"]*"', text): + raise ConvertError("unsupported relocation operator: " + text) + + def repl(m): + tok = m.group(0) + if tok.startswith(".") and not tok.startswith(".L"): + return tok + if tok.startswith("lo12"): + return tok + return self.sym(tok) + # rename identifiers outside :lo12: markers + parts = re.split(r'(:lo12:)', text) + return "".join(p if p == ":lo12:" else IDENT.sub(repl, p) for p in parts) + + def emit(self, s: str): + self.out.append(s) + + def section(self, segment: str, name: str): + self.in_dropped_section = False + if segment == "__DWARF" or segment == "__LD" or name in ("__compact_unwind", "__eh_frame"): + self.in_dropped_section = True + return + if name == "__guest_header": + self.emit('\t.section .guest_header,"a",@progbits') + elif name == "__mod_init_func": + self.emit('\t.section .guest_init,"aw",@progbits') + elif name == "__mod_term_func": + self.emit('\t.section .guest_fini,"aw",@progbits') + elif segment == "__TEXT" and name == "__text": + self.emit("\t.text") + elif segment == "__TEXT" and name in RODATA_SECTIONS: + self.emit('\t.section .rodata,"a",@progbits') + elif segment == "__TEXT": + self.emit("\t.text") + elif name in ("__bss", "__common"): + self.emit("\t.bss") + else: + self.emit("\t.data") + + def zerofill(self, args): + parts = [a.strip() for a in args.split(",")] + if len(parts) < 4: + return + segment, name, symbol, size = parts[:4] + align = int(parts[4]) if len(parts) > 4 else 0 + symbol = self.sym(symbol) + # a definition in either section: tentative definitions (-fcommon) + # are emitted as .comm, and __common here only holds zero-initialized + # ones + self.emit("\t.pushsection .bss") + self.emit(f"\t.p2align {align}") + self.emit(f"{symbol}:") + self.emit(f"\t.zero {size}") + self.emit("\t.popsection") + + def convert_line(self, raw: str): + text = strip_comment(raw) + stripped = text.strip() + if not stripped: + return + # labels + m = re.match(r'^([A-Za-z_.$][\w.$]*|"[^"]*"):(.*)$', stripped) + if m: + if not self.in_dropped_section: + self.emit(self.sym(m.group(1)) + ":") + rest = m.group(2).strip() + if rest: + self.convert_line(rest) + return + # symbol assignment (aliases) + m = re.match(r'^([A-Za-z_.$][\w.$]*)\s*=\s*(.+)$', stripped) + if m: + if not self.in_dropped_section: + self.emit(f"\t.set {self.sym(m.group(1))}, {self.operands(m.group(2))}") + return + parts = stripped.split(None, 1) + op = parts[0] + args = parts[1] if len(parts) > 1 else "" + + if op == ".section": + seg, _, rest = args.partition(",") + name = rest.split(",")[0].strip() + self.section(seg.strip(), name) + return + shorthand = { + ".text": ("__TEXT", "__text"), ".data": ("__DATA", "__data"), + ".const": ("__TEXT", "__const"), ".cstring": ("__TEXT", "__cstring"), + ".literal4": ("__TEXT", "__literal4"), ".literal8": ("__TEXT", "__literal8"), + ".literal16": ("__TEXT", "__literal16"), ".const_data": ("__DATA", "__const"), + ".static_data": ("__DATA", "__data"), ".mod_init_func": ("__DATA", "__mod_init_func"), + ".mod_term_func": ("__DATA", "__mod_term_func"), + } + if op in shorthand: + self.section(*shorthand[op]) + return + if op == ".zerofill": + self.zerofill(args) + return + if self.in_dropped_section: + return + if op in DROP_DIRECTIVES or op.endswith("_version_min"): + return + if op in (".tbss", ".tlv", ".tdata") or "@TLVP" in args: + raise ConvertError("thread-local storage is not supported: " + stripped) + if op == ".globl": + self.emit(f"\t.globl {self.sym(args.strip())}") + return + if op == ".private_extern": + s = self.sym(args.strip()) + self.emit(f"\t.hidden {s}") + return + if op in (".weak_definition", ".weak_reference", ".weak_def_can_be_hidden", ".weak"): + self.emit(f"\t.weak {self.sym(args.strip())}") + return + if op == ".comm" or op == ".lcomm": + a = [x.strip() for x in args.split(",")] + s = self.sym(a[0]) + align = 1 << int(a[2]) if len(a) > 2 else 1 + if op == ".lcomm": + self.emit(f"\t.local {s}") + self.emit(f"\t.comm {s},{a[1]},{align}") + return + if op == ".align": + self.emit(f"\t.p2align {args}") + return + if op.startswith("."): + self.emit(f"\t{op} {self.operands(args)}" if args else f"\t{op}") + return + # instructions + if "@GOTPAGEOFF" in args: + # ldr wN/xN, [xM, sym@GOTPAGEOFF] -> add xN, xM, :lo12:sym (the + # image is linked statically below 4 GB, so every GOT load can be + # relaxed to the address itself) + gm = re.match(r'^\s*([wx])(\d+|zr)\s*,\s*\[\s*(x\d+|sp)\s*,\s*([\w.$"]+)@GOTPAGEOFF\s*\]\s*$', args) + if op != "ldr" or not gm: + raise ConvertError("unsupported GOT access: " + stripped) + self.emit(f"\tadd x{gm.group(2)}, {gm.group(3)}, :lo12:{self.sym(gm.group(4))}") + return + self.emit(f"\t{op} {self.operands(args)}" if args else f"\t{op}") + + def run(self): + for i, raw in enumerate(self.lines): + try: + self.convert_line(raw) + except ConvertError as e: + raise ConvertError(f"line {i + 1}: {e}") from None + # a weak symbol is already global; a second directive only warns + weak = {line.split(None, 1)[1] for line in self.out if line.startswith("\t.weak ")} + out = [line for line in self.out + if not (line.startswith("\t.globl ") and line.split(None, 1)[1] in weak)] + return "\n".join(out) + "\n" + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(2) + with open(sys.argv[1], "r", encoding="utf-8", errors="surrogateescape") as f: + lines = f.read().split("\n") + try: + result = Converter(lines).run() + except ConvertError as e: + print(f"{sys.argv[1]}: {e}", file=sys.stderr) + sys.exit(1) + with open(sys.argv[2], "w", encoding="utf-8", errors="surrogateescape") as f: + f.write(result) + + +if __name__ == "__main__": + main() diff --git a/tools/guest_gl_stubs.py b/tools/guest_gl_stubs.py new file mode 100644 index 0000000000..3dc730e48d --- /dev/null +++ b/tools/guest_gl_stubs.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Generate the iOS guest's OpenGL ES entry points. + +The guest (ILP32 code, see port/ios/README.md) cannot link against the +host's libGLESv3, but it can call it: a host function receives the guest's +pointer arguments zero-extended, and 32-bit integers and floats go in the +same registers under both ABIs. What differs is + +- GLsizeiptr and GLintptr, 32-bit in the guest and 64-bit in the host; +- arguments passed on the stack (beyond the eighth integer argument), which + the guest packs by size while the host expects 8-byte slots; +- returned pointers, which point at host memory. + +For each function in the iOS list in port/linux/src/gl.h this writes a +guest wrapper that widens such arguments and calls an import named +hostgl_, which the host resolves to the real entry point. Functions +returning a string go through host_gl_get_string instead. + +Usage: guest_gl_stubs.py gl.h gl32.h gl2ext.h output.c imports.list +""" + +import re +import sys + +INTEGER_REGISTER_COUNT = 8 +WIDE_TYPES = {"GLsizeiptr", "GLintptr", "GLint64", "GLuint64", "GLint64EXT", "GLuint64EXT"} +FLOAT_TYPES = {"GLfloat", "GLclampf", "GLdouble", "GLclampd", "float", "double"} + + +def gles_functions(gl_header: str): + text = open(gl_header, encoding="utf-8").read() + start = text.index("/* GLES_FUNCTIONS_BEGIN */") + end = text.index("/* GLES_FUNCTIONS_END */") + return re.findall(r"X\((\w+)\)", text[start:end]) + + +def prototypes(*headers): + result = {} + pattern = re.compile(r"GL_APICALL\s+(.+?)\s*GL_APIENTRY\s+(\w+)\s*\((.*?)\);", re.S) + for header in headers: + text = open(header, encoding="utf-8").read() + for ret, name, params in pattern.findall(text): + params = " ".join(params.split()) + result.setdefault(name, (ret.strip(), params)) + return result + + +def split_parameter(param: str): + param = param.strip() + m = re.match(r"(.*?)(\w+)\s*$", param) + ptype, pname = m.group(1).strip(), m.group(2) + if not ptype: # unnamed + ptype, pname = param, None + return ptype, pname + + +def main(): + gl_header, gl32, gl2ext, output, imports = sys.argv[1:6] + functions = gles_functions(gl_header) + protos = prototypes(gl32, gl2ext) + out = [ + "/* generated by tools/guest_gl_stubs.py from port/linux/src/gl.h; do not edit */", + "#include ", + "#include ", + "#include ", + "", + "void host_gl_get_string(unsigned int name, int index, char *buffer, unsigned int size);", + "", + ] + table = [] + import_names = [] + for name in functions: + if name not in protos: + raise SystemExit(f"{name}: no prototype in the GLES headers") + ret, params = protos[name] + if params in ("void", ""): + plist = [] + else: + plist = [split_parameter(p) for p in params.split(",")] + if name == "glGetString": + out += [ + "static const GLubyte *GL_APIENTRY guest_glGetString(GLenum name)", + "{", + "\tstatic __thread char buffer[4][4096];", + "\tstatic __thread int next;", + "\tchar *result = buffer[next++ & 3];", + "", + "\thost_gl_get_string(name, -1, result, sizeof(buffer[0]));", + "\treturn (const GLubyte *)result;", + "}", + "", + ] + table.append(name) + continue + if name == "glShaderSource": + # an array of pointers: widen each to a host pointer + import_names.append("hostgl_glShaderSource") + out += [ + "void hostgl_glShaderSource(GLuint shader, GLsizei count, const unsigned long long *strings, " + "const GLint *lengths);", + "static void GL_APIENTRY guest_glShaderSource(GLuint shader, GLsizei count, " + "const GLchar *const *strings, const GLint *lengths)", + "{", + "\tunsigned long long wide[16];", + "\tGLsizei index;", + "", + "\tif (count > 16)", + "\t\tcount = 16;", + "\tfor (index = 0; index < count; index++)", + "\t\twide[index] = (unsigned long long)(unsigned int)strings[index];", + "\thostgl_glShaderSource(shader, count, wide, lengths);", + "}", + "", + ] + table.append(name) + continue + if "*" in ret: + raise SystemExit(f"{name}: returns a pointer; add a special case") + if any(p.count("*") > 1 for p, _ in plist): + raise SystemExit(f"{name}: takes an array of pointers; add a special case") + decl_params, host_params, call_args = [], [], [] + integer_index = 0 + for index, (ptype, pname) in enumerate(plist): + arg = f"a{index}" + decl_params.append(f"{ptype} {arg}") + is_pointer = "*" in ptype + base = ptype.replace("const", "").strip() + if base in FLOAT_TYPES and not is_pointer: + host_params.append(ptype) + call_args.append(arg) + continue + wide = base in WIDE_TYPES and not is_pointer + on_stack = integer_index >= INTEGER_REGISTER_COUNT + integer_index += 1 + if is_pointer: + if on_stack: + host_params.append("unsigned long long") + call_args.append(f"(unsigned long long)(unsigned int){arg}") + else: + host_params.append(ptype) + call_args.append(arg) + elif wide or on_stack: + host_params.append("long long") + call_args.append(f"(long long){arg}") + else: + host_params.append(ptype) + call_args.append(arg) + host_name = "hostgl_" + name + import_names.append(host_name) + out.append(f"{ret} {host_name}({', '.join(host_params) or 'void'});") + out.append(f"static {ret} GL_APIENTRY guest_{name}({', '.join(decl_params) or 'void'})") + out.append("{") + call = f"{host_name}({', '.join(call_args)})" + out.append(f"\t{'return ' if ret != 'void' else ''}{call};") + out.append("}") + out.append("") + table.append(name) + out.append("static const struct { const char *name; void *function; } guest_gl_functions[] =") + out.append("{") + for name in table: + out.append(f'\t{{ "{name}", (void *)guest_{name} }},') + out.append("};") + out.append("") + out.append("typedef void (*guest_gl_function)(void);") + out.append("guest_gl_function guest_gl_get_proc_address(const char *name)") + out.append("{") + out.append("\tunsigned long index;") + out.append("") + out.append("\tfor (index = 0; index < sizeof(guest_gl_functions) / sizeof(guest_gl_functions[0]); index++)") + out.append("\t{") + out.append("\t\tif (!strcmp(guest_gl_functions[index].name, name))") + out.append("\t\t\treturn (guest_gl_function)guest_gl_functions[index].function;") + out.append("\t}") + out.append("\treturn 0;") + out.append("}") + with open(output, "w", encoding="utf-8") as f: + f.write("\n".join(out) + "\n") + with open(imports, "w", encoding="utf-8") as f: + f.write("\n".join(import_names) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/guest_imports.py b/tools/guest_imports.py new file mode 100644 index 0000000000..41b94f5503 --- /dev/null +++ b/tools/guest_imports.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Generate the guest image's host import stubs. + +Every function the guest imports from the host (port/ios/host_imports.list, +plus the generated OpenGL ES list) gets a stub in the image that loads the +host's 64-bit function address from __host_import_table and branches to it. +The arguments are left untouched in their registers; the host fills in the +table when it loads the image (port/ios/host/host_loader.c). + +Usage: guest_imports.py [--host-table table.c] output.s list... + +With --host-table, also writes the host's name-to-function table for the +names in the lists (OpenGL names, which the host resolves at run time, are +not part of it). +""" + +import sys + + +def main(): + arguments = sys.argv[1:] + host_table = None + if arguments[0] == "--host-table": + host_table = arguments[1] + arguments = arguments[2:] + output, lists = arguments[0], arguments[1:] + names = [] + for path in lists: + for line in open(path, encoding="utf-8"): + line = line.split("#", 1)[0].strip() + if line and line not in names: + names.append(line) + out = ["/* generated by tools/guest_imports.py; do not edit */", "\t.text"] + for index, name in enumerate(names): + offset = index * 8 + out += [ + f"\t.globl {name}", + f"\t.type {name},%function", + "\t.p2align 2", + f"{name}:", + f"\tadrp x16, __host_import_table+{offset}", + f"\tldr x16, [x16, :lo12:__host_import_table+{offset}]", + "\tbr x16", + ] + out += [ + "\t.section .rodata", + "\t.globl __host_import_names", + "__host_import_names:", + ] + out += [f'\t.asciz "{name}"' for name in names] + out += [ + "\t.p2align 2", + "\t.globl __host_import_count", + "__host_import_count:", + f"\t.long {len(names)}", + "\t.data", + "\t.p2align 3", + "\t.globl __host_import_table", + "__host_import_table:", + f"\t.zero {max(len(names), 1) * 8}", + ] + with open(output, "w", encoding="utf-8") as f: + f.write("\n".join(out) + "\n") + if host_table: + host_names = [n for n in names if not n.startswith("hostgl_")] + table = ["/* generated by tools/guest_imports.py; do not edit */", + "#include ", ""] + def symbol(n): + # hostposix_ imports port/linux/src/posix.h's posix_ + return "posix_" + n[len("hostposix_"):] if n.startswith("hostposix_") else n + table += [f"extern void {symbol(n)}(void);" for n in host_names] + table += ["", "static const struct { const char *name; void *function; } imports[] =", "{"] + table += [f'\t{{ "{n}", (void *){symbol(n)} }},' for n in host_names] + table += ["};", "", + "void *host_resolve_import(const char *name)", + "{", + "\tunsigned long index;", + "", + "\tfor (index = 0; index < sizeof(imports) / sizeof(imports[0]); index++)", + "\t{", + "\t\tif (!strcmp(imports[index].name, name))", + "\t\t\treturn imports[index].function;", + "\t}", + "\treturn NULL;", + "}"] + with open(host_table, "w", encoding="utf-8") as f: + f.write("\n".join(table) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/guest_posix_stubs.py b/tools/guest_posix_stubs.py new file mode 100644 index 0000000000..ca6fc37ad1 --- /dev/null +++ b/tools/guest_posix_stubs.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Generate the iOS guest's side of port/linux/src/posix.h. + +The posix_*.c helpers run in the iOS host (they are the platform +layer's only contact with the C library's file system and sockets). Their +interface is 32-bit clean, so the guest can call them directly, except for +errno: the host's is not the guest's. Each generated wrapper calls the host +function through an import named hostposix_ and copies the host's +errno into the guest's afterwards. + +Usage: guest_posix_stubs.py posix.h output.c imports.list +""" + +import re +import sys + + +def main(): + header, output, imports = sys.argv[1:4] + text = open(header, encoding="utf-8").read() + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + prototypes = re.findall(r"^([A-Za-z_][\w \*]*?)\b(posix_\w+)\s*\(([^;]*?)\);", text, re.M | re.S) + out = [ + "/* generated by tools/guest_posix_stubs.py from port/linux/src/posix.h; do not edit */", + "#include ", + '#include "posix.h"', + "", + "int host_errno(void);", + "", + ] + names = [] + for ret, name, params in prototypes: + ret = " ".join(ret.split()) + params = " ".join(params.split()) + args = [] + if params != "void": + for param in params.split(","): + m = re.search(r"(\w+)\s*$", param.strip()) + args.append(m.group(1)) + host_name = "hostposix_" + name[len("posix_"):] + names.append(host_name) + out.append(f"{ret} {host_name}({params});") + out.append(f"{ret} {name}({params})") + out.append("{") + call = f"{host_name}({', '.join(args)})" + if ret == "void": + out.append(f"\t{call};") + out.append("\terrno = host_errno();") + else: + out.append(f"\t{ret} host_result = {call};") + out.append("") + out.append("\terrno = host_errno();") + out.append("\treturn host_result;") + out.append("}") + out.append("") + with open(output, "w", encoding="utf-8") as f: + f.write("\n".join(out)) + with open(imports, "w", encoding="utf-8") as f: + f.write("\n".join(names) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/ios_angle.py b/tools/ios_angle.py new file mode 100644 index 0000000000..8e62b209e0 --- /dev/null +++ b/tools/ios_angle.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Fetch the pinned, hash-verified ANGLE Metal frameworks used by the iOS host.""" +import hashlib +import base64 +from pathlib import Path +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +REVISION = 'c053bf85793bbb83016b1196d04e5df3594b9bcc' +VERSION = 'v2.1.28252' +SHA256 = '59e4b1f68956c92441cde4dca0e9eb1a835bbccd107cefdd1d3d3d60e27410be' + +def prepare(): + directory = ROOT / 'build/third_party/angle' + directory.mkdir(parents=True, exist_ok=True) + archive = directory / 'package.zip' + if not archive.exists(): + data = urllib.request.urlopen(f'https://github.com/EdgeFirstAI/angle-package/releases/download/{VERSION}/angle-xcframeworks-{VERSION}.zip', timeout=60).read() + if hashlib.sha256(data).hexdigest() != SHA256: + raise RuntimeError('ANGLE download checksum mismatch') + archive.write_bytes(data) + if hashlib.sha256(archive.read_bytes()).hexdigest() != SHA256: + raise RuntimeError('Cached ANGLE archive checksum mismatch; remove package.zip and retry') + with zipfile.ZipFile(archive) as package: + for info in package.infolist(): + if info.is_dir() or any(p.startswith('._') for p in Path(info.filename).parts): + continue + if '/ios-arm64' not in info.filename and '/macos-arm64/' not in info.filename and info.filename != 'dist/BUILD_INFO.txt': + continue + target = directory / info.filename + if not target.resolve().is_relative_to(directory.resolve()): + raise RuntimeError('Invalid ANGLE archive path') + target.parent.mkdir(parents=True, exist_ok=True) + data = package.read(info) + if not target.exists() or target.read_bytes() != data: + target.write_bytes(data) + target.chmod((info.external_attr >> 16) & 0o777 or 0o644) + stamp=directory/'source-revision' + refresh=not stamp.exists() or stamp.read_text().strip()!=REVISION + # Include upstream notices for the translator and bundled dependencies. + notices = ROOT/'build/ios/licenses' + notices.mkdir(parents=True, exist_ok=True) + embedded = ['src/common/third_party/xxhash/LICENSE', 'src/third_party/ceval/LICENSE', + 'third_party/glslang/LICENSE', 'third_party/spirv-headers/LICENSE', + 'third_party/spirv-tools/LICENSE'] + for source in embedded: + target=notices/('ANGLE-'+source.split('/')[-2]+'.txt') + if refresh or not target.exists(): + target.write_bytes(urllib.request.urlopen(f'https://raw.githubusercontent.com/google/angle/{REVISION}/{source}', timeout=30).read()) + external = [ + ('SPIRV-Cross','external/github.com/KhronosGroup/SPIRV-Cross','b8fcf307f1f347089e3c46eb4451d27f32ebc8d3','LICENSE'), + ('abseil','chromium/src/third_party/abseil-cpp','d8e483edd8b44da1845874ee84b42489589bb90f','LICENSE'), + ('zlib','chromium/src/third_party/zlib','8b3aa8a1cd7585c0c4c67351481227b046a662a0','LICENSE'), + ] + for name, repository, revision, source in external: + target=notices/f'ANGLE-{name}.txt' + if refresh or not target.exists(): + data=urllib.request.urlopen(f'https://chromium.googlesource.com/{repository}/+/{revision}/{source}?format=TEXT', timeout=30).read() + target.write_bytes(base64.b64decode(data)) + # Fetch public headers and the upstream license at the exact binary source revision. + for source, target in [('include/EGL/eglext_angle.h', ROOT/'build/ios/gl_include/EGL/eglext_angle.h'), ('LICENSE', ROOT/'build/ios/licenses/ANGLE.txt')]: + target.parent.mkdir(parents=True, exist_ok=True) + if refresh or not target.exists(): + target.write_bytes(urllib.request.urlopen(f'https://raw.githubusercontent.com/google/angle/{REVISION}/{source}', timeout=30).read()) + + stamp.write_text(REVISION+'\n') + +if __name__ == '__main__': + prepare() diff --git a/tools/ios_asm_convert.py b/tools/ios_asm_convert.py new file mode 100644 index 0000000000..eff2229137 --- /dev/null +++ b/tools/ios_asm_convert.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Lift ILP32 guest accesses into a 4 GB aligned iOS arena. + +The compiler reserves x15 (scratch) and x27 (arena base). Disk pointers and +structure layouts remain 32-bit. Only memory accesses and indirect branches +are widened. Direct branches and PC-relative references keep their offsets. +The host enters with x27 set, and preserves it across every host call. +""" + +import re +import sys +from pathlib import Path + +from guest_asm_convert import Converter, ConvertError + + +def lift(assembly: str) -> str: + """Rewrite ELF assembly, rejecting unsupported addressing forms.""" + output = [] + for number, line in enumerate(assembly.splitlines(), 1): + text = line.strip() + if not text or text.startswith(".") or text.endswith(":"): + output.append(line) + continue + if re.search(r"\b[wx](15|27)\b", text) and not (re.match(r"(?:stp|ldp) .*\[sp", text) and "x15" not in text): + raise ConvertError(f"line {number}: reserved register used: {text}") + op, _, args = text.partition(" ") + if op in ("adr", "adrp"): + # The ILP32 backend assumes symbol addresses are zero-extended. + # Actual PC-relative addresses include the native arena bias. + register = args.split(",", 1)[0].strip() + output.extend([line, f"\tmov w{register[1:]}, w{register[1:]}"]) + continue + if op in ("br", "blr"): + if not re.fullmatch(r"x\d+", args.strip()): + raise ConvertError(f"line {number}: unsupported branch: {text}") + output.extend([f"\tmov w15, w{args.strip()[1:]}", f"\torr x15, x15, x27", f"\t{op} x15"]) + continue + match = re.search(r"\[(x\d+|sp)([^\]]*)\](!?)(.*)$", args) + if not match: + output.append(line) + continue + base, offset, pre, tail = match.groups() + if base == "sp": + output.append(line) + continue + # Clang emits immediate pre/post increments. Preserve the original + # register's representation and the instruction's flag behavior. + update = None + if pre: + increment = offset.lstrip(", ") + if not re.fullmatch(r"#-?\d+", increment): + raise ConvertError(f"line {number}: unsupported pre-index: {text}") + update = increment + elif tail.strip(): + increment = tail.lstrip(", ") + if not re.fullmatch(r"#-?\d+", increment): + raise ConvertError(f"line {number}: unsupported post-index: {text}") + update = increment + output.append(f"\tmov w15, w{base[1:]}") + output.append("\torr x15, x15, x27") + rewritten = args[:match.start()] + "[x15" + offset + "]" + pre + tail + output.append(f"\t{op} {rewritten}") + if update: + value = int(update[1:]) + output.append(f"\t{'sub' if value < 0 else 'add'} {base}, {base}, #{abs(value)}") + return "\n".join(output) + "\n" + + +def main(): + """Convert the compiler's Darwin assembly and lift guest addressing.""" + source, destination = map(Path, sys.argv[1:]) + destination.write_text(lift(Converter(source.read_text().splitlines()).run())) + + +if __name__ == "__main__": + main() diff --git a/tools/ios_bridges.py b/tools/ios_bridges.py new file mode 100644 index 0000000000..c01b5e4fe1 --- /dev/null +++ b/tools/ios_bridges.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Generate typed guest-to-Darwin bridges; translate pointers, never GL offsets.""" +import re +from pathlib import Path +from guest_gl_stubs import gles_functions, prototypes, split_parameter, FLOAT_TYPES, WIDE_TYPES + +ROOT=Path(__file__).resolve().parents[1] +OUT=ROOT/'build/ios/host' +OUT.mkdir(parents=True,exist_ok=True) +lines=['/* Generated typed bridges. */','#include "ios_host.h"','#include "guest_host.h"','#include "posix.h"', + '#include ','#include ','#include ','#include ','#include "host_debug.h"','#include "host_graphics.h"'] +table=[] + +def declarations(path,pattern): + text=re.sub(r'/\*.*?\*/','',path.read_text(),flags=re.S) + text=re.sub(r'__attribute__\s*\(\(.*?\)\)','',text) + return re.findall(r'^([\w \t*]+?)\b('+pattern+r')\s*\(([^;]*?)\)\s*;',text,re.M|re.S) + +for header,pattern in [(ROOT/'port/runtime/guest/runtime/guest_host.h',r'host_\w+'),(ROOT/'port/linux/src/posix.h',r'posix_\w+')]: + for ret,name,params in declarations(header,pattern): + ret=' '.join(ret.split());params=' '.join(params.split()) + decl=[];args=[] + for index,param in enumerate([] if params=='void' else params.split(',')): + kind,_=split_parameter(param);arg=f'a{index}' + if '*' in kind: + decl.append(f'uint64_t {arg}') + opaque=name in ('posix_directory_next','posix_directory_close') and index==0 + args.append(f'({kind})'+(f'(uintptr_t){arg}' if opaque else f'host_pointer({arg})')) + else: + decl.append(f'{kind} {arg}');args.append(arg) + imported='hostposix_'+name[6:] if name.startswith('posix_') else name + bridge='ios_bridge_'+name + return_type='uint32_t' if '*' in ret else ret + lines.append(f'static {return_type} {bridge}({", ".join(decl) or "void"}) {{') + call=f'{name}({", ".join(args)})' + if '*' in ret:call=f'(uint32_t)(uintptr_t){call}' + lines.append((' ' if ret=='void' else ' return ')+call+';\n}') + table.append((imported,bridge)) + +gl=ROOT/'build/ios/gl_include' +protos=prototypes(str(gl/'GLES3/gl32.h'),str(gl/'GLES2/gl2ext.h')) +for name in gles_functions(str(ROOT/'port/linux/src/gl.h')): + if name=='glGetString':continue + ret,params=protos[name] + plist=[] if params=='void' else [split_parameter(p) for p in params.split(',')] + decl=[];args=[];integer_index=0 + for index,(kind,_) in enumerate(plist): + arg=f'a{index}';pointer='*' in kind;base=kind.replace('const','').strip() + if base in FLOAT_TYPES and not pointer: + decl.append(f'{kind} {arg}');args.append(arg);continue + on_stack=integer_index>=8;integer_index+=1 + if pointer: + decl.append(f'uint64_t {arg}') + offset=name in ('glVertexAttribPointer','glVertexAttribIPointer','glDrawElements','glDrawElementsBaseVertex') and index==len(plist)-1 + # BaseVertex's final parameter is an integer; its indices are #3. + offset=offset or (name=='glDrawElementsBaseVertex' and index==3) + args.append(f'({kind})'+(f'(uintptr_t){arg}' if offset else f'host_pointer({arg})')) + elif base in WIDE_TYPES or on_stack: + decl.append(f'long long {arg}');args.append(f'({kind}){arg}') + else:decl.append(f'{kind} {arg}');args.append(arg) + bridge='ios_bridge_'+name + lines.append(f'static {ret} {bridge}({", ".join(decl) or "void"}) {{') + signature=', '.join(t for t,_ in plist) or 'void' + lines.append(f' typedef {ret} (GL_APIENTRY *Function)({signature});') + lines.append(f' static Function function; if(!function) function=(Function)halo_graphics_proc("{name}");') + lines.append(f' if(!function) host_fatal("OpenGL ES entry point unavailable: {name}");') + if name=='glBindFramebuffer': + lines.append(' if(!a1) a1=host_ios_default_framebuffer();') + if name=='glShaderSource': + lines.extend([' const uint64_t *raw=host_pointer(a2); const GLchar *strings[16];', + ' if(a1<0 || a1>16) host_fatal("invalid shader string count");', + ' for(int i=0;ilen(data) or size>memory or address+memory>0x100000000: + raise ValueError('Invalid guest segment bounds') + segments.append((flags,address,size,memory,data[start:start+size])) + if len(segments)!=2 or [s[0] for s in segments]!=[5,6]: + raise ValueError('Guest must contain exactly one RX and one RW load segment') + code,writable=segments + if code[1]!=0x88000000 or writable[1]%0x4000 or code[1]+code[3]>writable[1]: + raise ValueError('Unexpected guest layout or page alignment') + destination.mkdir(parents=True,exist_ok=True) + code_bytes=code[4].ljust(writable[1]-code[1],b'\0') + (destination/'guest-code.bin').write_bytes(code_bytes) + (destination/'guest-data.bin').write_bytes(writable[4]) + (destination/'guest_image.h').write_text( + '/* Generated guest image layout. */\n#pragma once\n' + f'#define IOS_GUEST_CODE_SIZE {len(code_bytes)}u\n' + f'#define IOS_GUEST_DATA_ADDRESS 0x{writable[1]:x}u\n' + f'#define IOS_GUEST_DATA_SIZE {writable[2]}u\n' + f'#define IOS_GUEST_IMAGE_END 0x{writable[1]+writable[3]:x}u\n' + 'extern const unsigned char halo_guest_code[], halo_guest_data[];\n') + (destination/'guest_image.S').write_text( + '/* Guest code is covered by the enclosing app code signature. */\n' + '.section __TEXT,__halocode,regular,pure_instructions\n.p2align 14\n' + '.globl _halo_guest_code\n_halo_guest_code:\n' + f'.incbin "{(destination/"guest-code.bin").resolve()}"\n' + '.section __DATA_CONST,__halodata\n.p2align 3\n' + '.globl _halo_guest_data\n_halo_guest_data:\n' + f'.incbin "{(destination/"guest-data.bin").resolve()}"\n') + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('elf',type=Path);parser.add_argument('output',type=Path) + args=parser.parse_args();embed(args.elf,args.output) diff --git a/tools/ios_extract_assets.py b/tools/ios_extract_assets.py new file mode 100644 index 0000000000..4616ab6e34 --- /dev/null +++ b/tools/ios_extract_assets.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Inspect and extract Halo map files from a user's Xbox XISO without changing them.""" +import argparse +import hashlib +import json +import struct +from pathlib import Path + + +def map_entries(image): + """Read bounded XDVDFS directory trees and return map file extents.""" + length=image.seek(0,2) + for base in (0,0xFD90000,0x2080000,0x18300000): + image.seek(base+32*2048);header=image.read(2048) + if header[:20]==b'MICROSOFT*XBOX*MEDIA':break + else:raise ValueError('No Xbox filesystem header found') + root,size=struct.unpack_from('length or size>16*1024*1024 or sector in visited: + raise ValueError('Invalid or cyclic directory extent') + visited.add(sector);image.seek(extent);data=image.read(size);nodes=set() + def node(offset): + if offset in nodes or offset+14>len(data):raise ValueError('Invalid directory tree') + nodes.add(offset) + left,right,sector,size,attr,count=struct.unpack_from('len(data):raise ValueError('Invalid filename length') + name=data[offset+14:offset+14+count].decode('ascii') + if not name or name in ('.','..') or any(c in name for c in '/\\\0'):raise ValueError('Invalid filename') + if left:yield from node(left*4) + path=prefix/name + if attr&16: + yield from directory(sector,size,path) + elif path.parts[0].lower()=='maps' and path.suffix.lower()=='.map': + start=base+sector*2048 + if start+size>length:raise ValueError('Map extends beyond image') + yield path,start,size + if right:yield from node(right*4) + yield from node(0) + return list(directory(root,size,Path())) + + +def main(): + """Report exact cache build IDs; optionally copy the maps unchanged.""" + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('image',type=Path) + parser.add_argument('--output',type=Path,help='new output directory; existing files are never replaced') + args=parser.parse_args();manifest=[] + with args.image.open('rb') as image: + for path,start,size in map_entries(image): + image.seek(start);header=image.read(2048) + if header[:4]!=b'daeh' or header[-4:]!=b'toof':raise ValueError(f'Invalid Halo cache header: {path}') + build=header[64:96].split(b'\0',1)[0].decode('ascii') + entry={'path':str(path),'bytes':size,'build':build,'version':struct.unpack_from('