Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
55 changes: 55 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
@@ -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
155 changes: 155 additions & 0 deletions BROWSER_LOCAL.md
Original file line number Diff line number Diff line change
@@ -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 <http://127.0.0.1:8767/> 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 <http://127.0.0.1:8767/?data=/assets/> 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.
53 changes: 53 additions & 0 deletions BROWSER_NATIVE_INVITES.md
Original file line number Diff line number Diff line change
@@ -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.
Loading