# Multiplayer engine build

This directory contains the optional browser multiplayer runtime. The original
single-player `../index.js`, `../index.wasm`, and shared `../index.data` stay separate.
No additional copy of the 16,641,245-byte game package is published here.

## Pinned inputs

- Upstream source: `../engine-source.tar.gz`, BelgianChocolateDuke3D commit
  `d3fe31dfe7b5c0b2ab7c6da42709eaa281602872`.
- Source changes, applied in order: `../respawn-source.patch`,
  `../save-names-source.patch`, `../network-source.patch`,
  `../tournament-source.patch`, `../owned-content-source.patch`,
  `../menu-exit-source.patch`, `../gyro-state-source.patch`,
  `../save-cancel-source.patch`.
- Emscripten **3.1.61** (`67fa4c16496b157a7fc3377afd69ee0445e8a6e3`), release SDK
  `28e4a74b579b4157bda5fc34f23c7d3905a8bd6c`.
- SDK ports: SDL **2.28.4**, SDL_mixer **2.8.0**, MIDI enabled.
- `SOURCE_DATE_EPOCH=1719753020` fixes the source's compilation-date string.
- `../index.data` SHA-256:
  `2e54b24e3d5d6d714145dadea7df7ac3710d25ca088a016021cd9d35c68be6dc`.

The binary-only `../patch-respawn.mjs` and `../patch-save-names.mjs` are for the old
precompiled single-player engine. **Do not apply them to a new build.** The equivalent
C patches above preserve both fixes when rebuilding multiplayer.

## Rebuild

Install and activate Emscripten 3.1.61 using the official
[emsdk instructions](https://emscripten.org/docs/getting_started/downloads.html).
Use `emsdk install 3.1.61` and `emsdk activate 3.1.61`, rather than `latest`.
The build needs an activated SDK, Bash, Node, `tar`, `patch`, and `cmp`.
CMake, Ninja, Docker, and a system compiler are unnecessary.

```sh
bash duke3d/vendor/network/build-network.sh \
  /tmp/duke-network-output \
  /tmp/duke-network-clean-work \
  /path/to/emsdk
```

The work directory must not already contain `source`, `assets`, or `build`.
The script creates a separate source tree, applies all eight C patches, extracts
all **201** package entries, and compiles the exact upstream CMake source list
with `web_multi.c` replacing the native UDP `mmulti.c`.

The output directory receives only `index.js` and `index.wasm`. Temporary original
runtime, source, and packaged data remain in the work directory for inspection.
The build refuses a different Emscripten version, nonmatching generated package,
changed preload offsets, unexpected runtime patch signatures, or invalid WASM.

## Runtime compatibility

`patch-network-runtime.mjs` applies guarded changes to the generated runtime:

- Software framebuffer DPR stays at 1; CSS scales the 640×480 image.
- `Module['touchMouseLook']` feeds SDL's existing pointer-lock state callback, and
  the callback registers even when this flag starts false on a browser without
  Pointer Lock. The shell emits `pointerlockchange` when this flag changes.
- The restricted save-filesystem adapter and completed writable-close event are added
  by `../patch-save-runtime.mjs`; see `../SAVE-BRIDGE.md`. Import save slots in
  `Module.preRun` before native main scans slot names.
- The package's build-directory path and its two load-dependency identifiers are
  normalized to `index.data`, so different work directories produce identical code.
- `../patch-owned-content-runtime.mjs` replaces only `/DUKE3D.GRP` when the shell
  supplies a validated personal archive in `Module.dukeOwnedContent`. The hook runs
  after all packaged files are extracted and before the final package dependency
  is released. It validates archive type, size, header, directory and complete byte
  coverage, adopts the bytes into MEMFS, clears the input reference, and sets
  `Module.dukeOwnedContentMounted=true`. Invalid content aborts startup. With no
  personal content, the original shareware archive remains in place.

Closure is disabled, preserving the network `Module['dukeNet']` callback contract
and readable runtime names for the guarded patch. C and WASM remain optimized
with `-O3 -flto`; Asyncify remains enabled for the engine's yielding waits.

`EXIT_RUNTIME=1` enables native termination and the shell's `Module.onExit(status)`
callback. `menu-exit-source.patch` routes confirmed browser-menu exits through a
guarded `dukeBrowserMenuExit` function: menus 500/501/502 report
`Module.dukeExitReason='quit'`, while menu 503 reports `'title'`. Multiplayer title
exit remains disabled. The original confirmation and cancellation inputs are
retained, with warnings that unsaved progress will be lost and a multiplayer match
will end for everyone. The shell maps `quit` to the game catalog and `title` to
the Duke start screen.

Before setting the reason and calling `emscripten_force_exit(0)`, the function
stops native input delivery and tournament scoring, closes any demo file, runs
the existing audio/timer/engine shutdown, closes group files, and quits SDL.
Force exit clears Emscripten keepalive state; runtime cleanup runs before the
single `onExit` callback. This path skips the desktop exit screens and native
network waits. The shell owns save synchronization and the explicit relay leave
after the callback; it must not treat a confirmed menu exit as an engine failure.
Incoming multiplayer quit packets and other native error paths keep their
existing handlers. The filesystem save adapter remains usable for the final sync.

Memory growth is enabled with a 256 MiB maximum. The original initial allocation
of 125,304,832 bytes is explicitly retained, so the shareware build does not reserve
a larger initial heap. Setting the initial value is necessary: with growth enabled,
this SDK otherwise selects 16 MiB, below the engine's static data requirement.
The fixed-heap build aborted during native World Tour startup: the engine's ART
cache allocation grows from 6,815,872 bytes for shareware to 20,321,728 bytes for
World Tour (`Engine/src/tiles.c` sums padded tile sizes). Growth lets that extra
allocation succeed without forcing its cost on the shareware edition.

The shell may provide the existing package through `Module.getPreloadedPackage`.
Its in-memory configuration patch at byte offsets 16635895–16638699 remains valid:
all original preload names and offsets are checked after every rebuild. The shell
must resolve the new runtime's WASM to this directory and shared data to `../index.data`.

The personal-content hook works with both synchronous `getPreloadedPackage` and
asynchronous package downloads; it does not depend on user `preRun` callback order.
The shell must verify the supported edition/hash before providing the bytes, and
may check `dukeOwnedContentMounted` in `onRuntimeInitialized`. No general filesystem
write API is exposed. The source archive and packaged data are unchanged.

`owned-content-source.patch` recognizes the World Tour base GRP CRC `982AFE4A`
(44,356,548 bytes) as classic Atomic-compatible content, adds its diagnostic name,
and applies the existing v1.5 CON consistency check. Use only the scripts inside
that GRP. Its four classic episodes fit this engine's four-volume arrays; World
Tour's separate fifth-episode CON/ART/MAP files are unsupported and must not be
installed as loose files. The original 201-file package has no loose CON/ART/MAP
overrides: only its GRP, MIDI instruments, `duke3d.cfg`, and `timidity.cfg`.

## Verification record — 2026-09-21

- Complete single-player baseline compiled from the pinned source with both C fixes.
- Complete network build compiled with the replacement transport and its source patch.
- Repeated complete network builds matched byte-for-byte with a fixed build date.
- A fresh work directory also reproduced both artifacts after package-path normalization.
- Generated data matched the original SHA-256, and all 201 preload entries matched.
- Runtime syntax and WebAssembly validation passed.
- The final protocol-validator source patch was rebuilt from scratch through the
  checked-in build script, retaining respawn and automatic save names. The reviewed
  build also includes the native taunt terminator and legacy map-name packet fixes.
- Browser gameplay and two-client network checks are recorded in `../../CHANGELOG.md`.

Final engine build inputs:

| Input | SHA-256 |
| --- | --- |
| `../network-source.patch` | `24583921c1b2acd4f7e8cf85c42bf9fa99cb6bd9e72b59827e665c1020f61ec6` |
| `../web_multi.c` | `adc05381d37198b2f5d154b9e69ffc2284061d4c1d82031acdc28684f66a7cdb` |

Final generated artifacts:

| File | Bytes | SHA-256 |
| --- | ---: | --- |
| `index.js` (save cancellation 2026-09-24) | 214182 | `ba48c5b3a58500eca73f9394c2dc7e1449cfbc408c2aefdd23a3fb1d893de577` |
| `index.wasm` | 2109516 | `ac3b753a77edb80a6536697de1f422a9746886f46bb3c8c8f136bf618b30e738` |

The first network build was produced on macOS arm64 with an isolated emsdk under
`/tmp/luck4luck-duke-build/emsdk`; its SDK-manager checkout was
`c59d6e841da55c2c21af32004c4c173cbd1c0f10`. No shell profile or system toolchain was changed.

## Personal-content verification — 2026-09-22

- Two complete builds in separate fresh work directories produced identical JS and
  WASM bytes; each canonical build passed package comparison, all 201 preload
  offsets, JavaScript syntax, and WebAssembly validation.
- Runtime hook checks used the user's local 44,356,548-byte World Tour archive;
  invalid header, empty/truncated/extra data, and an unexpected preloaded archive
  failed before any write. Absent personal content retained normal shareware flow.
- The actual generated `processPackageData` function was exercised with all 201
  requests: personal content mounted after the final extraction and before the
  data dependency was released, writing exactly `/DUKE3D.GRP` once.
- Shared `index.data` and the pinned engine source archive remain unchanged.
- Direct instantiation of the generated WASM verified the unchanged initial memory,
  successful 16 MiB growth, and rejection of a growth beyond the 256 MiB maximum.
- Browser gameplay and deployment verification are recorded by the shell integration
  in `../../CHANGELOG.md`; a successful engine build alone does not prove gameplay.

## Confirmed menu-exit verification — 2026-09-22

- Two complete builds in fresh directories produced identical JS and WASM; both
  passed package comparison, all 201 preload offsets, runtime syntax and WASM
  validation. Network and tournament artifact pairs are byte-identical.
- A native harness compiled the actual patched exit function with instrumented
  dependencies. Solo quit, solo title, multiplayer quit, multiplayer title refusal,
  recording cleanup and open-demo cleanup passed. Successful exits performed
  cleanup before setting the reason and forcing exit; repeat calls were ignored.
- Source assertions retained Space, Enter, keypad Enter, Y and mouse confirmation,
  Esc/N/right-click cancellation, and the existing multiplayer title-menu gate.
- Generated runtime inspection confirmed `noExitRuntime` defaults to false,
  `emscripten_force_exit` clears keepalive state, and runtime shutdown precedes
  `Module.onExit`. Browser interaction and shell routing are verified separately
  in `../../CHANGELOG.md`.

## Native input-state bridge — 2026-09-22

`gyro-state-source.patch` adds a bounded gate for optional motion look. The existing
SDL event pump computes the local player's native state before processing events;
`_nextpage` already runs this pump before presenting every frame. Only a state
transition crosses into JavaScript: `Module.dukeGameplayActive` receives a boolean,
then optional `Module.dukeInputState(active)` is called. The first observation also
emits, including initial false. A missing or throwing callback cannot abort C.

True requires exactly `MODE_GAME`, `ready2send`, a live valid local player sprite,
and ordinary player control. Native menus (including save/load/confirmation), demo,
chat, end/restart, pause, help, map, console, cheat entry, death and remote-camera view
produce false. Returning from those screens emits true again. Demo recording remains
playable; demo playback does not. Confirmed menu exit emits false before shutdown.
The shell must additionally gate its own overlays, visibility, device permission
and selected controls. This bridge never reads or exposes player coordinates and
adds no JavaScript polling timer or per-frame JS allocation.

Verification:

- The canonical pinned build completed with all seven patches, original data and
  all 201 preload offsets unchanged, valid JavaScript and valid WASM.
- The exact same generated files are installed in network and tournament folders.
- `node --test dev/duke-gyro/native-state.test.mjs` reconstructs the patched source,
  compiles the actual C predicate, and covers all mode bits, pause/help/map/console,
  death/respawn, camera/cheat states, invalid indices, load/exit readiness and
  transition deduplication. It also checks the actual JS bridge's boolean contract,
  missing/failing callbacks, and the frame/event/shutdown hook locations.
- Physical sensor behavior and browser interactions require shell-level testing;
  native harness and build checks do not assert real-device gyro quality.

## Armed cheat-prefix input fix — 2026-09-23

Ordinary keyboard or touch strafe-right uses D. The original cheat parser also
records that key as `cheat_phase=-1`, waiting for a possible N prefix, while
`player.c` continues accepting gameplay input whenever `cheat_phase <= 0`.
The motion-input gate now uses the same predicate, so strafe-right no longer
suspends gyro look indefinitely. Positive cheat-entry phases still suppress it.
No game rules, key bindings, inventory state or network packets change in C.

The actual-C state regression covers -1 staying live without emitting a duplicate
callback, phases 1 and 2 suppressing input, and restoration to -1 or 0. The
canonical Emscripten 3.1.61 build applies the same seven patches, verifies the
unchanged shareware package and all 201 preload offsets, and validates the
generated runtime and WebAssembly. The resulting native artifacts are shared by
the network and tournament runtimes. Shell interaction is verified separately.

## Cancelled save-name correction — 2026-09-24

The final `save-cancel-source.patch` restores native slot labels from actual save
files when a name edit is cancelled. Before this fix, typing a name in an empty
slot and pressing Escape left a name in Load Game despite no file having been
written; selecting it produced the misleading version/player-count load error.
`readsavenames()` now clears each label before checking its file and closes
unsupported-version files. Existing saved labels are restored after cancelled
renames. A failed immediate solo save also restores file-backed labels, preserves
the previous quicksave slot, and reports `SAVE FAILED`. Multiplayer's deferred
save dispatch and the binary save format are unchanged.

`node --test dev/duke-saves/native-save-menu.test.mjs` compiles the actual patched
C slot-reading and menu branches. It checks cancelled empty and existing slots,
missing and invalid files, handle closure, failed and successful solo saves,
automatic names, and accepted deferred multiplayer dispatch. The canonical build
applies eight patches and passes shared-package comparison, all 201 preload
offsets, JavaScript syntax and WebAssembly validation. Both runtime directories
receive byte-identical output. Browser and production checks are recorded in the
coordinating change document.
