Skip to content

fix: run correctly on modern Emscripten + release-grade performance flags (~15x less CPU)#14

Open
zackarychapple wants to merge 4 commits into
cloudflare:mainfrom
zackarychapple:main
Open

fix: run correctly on modern Emscripten + release-grade performance flags (~15x less CPU)#14
zackarychapple wants to merge 4 commits into
cloudflare:mainfrom
zackarychapple:main

Conversation

@zackarychapple

@zackarychapple zackarychapple commented Jul 6, 2026

Copy link
Copy Markdown

Summary

This port no longer builds or runs correctly on current Emscripten (tested with 6.0.2): compilation fails under the new clang's C23 default, and once that is worked around, a latent ABI bug corrupts memory at runtime (garbled 3D view, phantom "Player 4 left the game" messages, wrong HUD). This PR fixes both, migrates index.html to the current runtime APIs, and replaces the debug-grade build flags that were shipping in release builds.

Correctness

  • boolean was a different size in different translation units (src/doomtype.h): TUs that had already included <stdbool.h> (via SDL headers) got a 1-byte bool; the rest got a 4-byte enum. Four-byte stores to globals like netgame clobbered the three adjacent bytes — corrupting playeringame[], the deathmatch flag, and renderer view state. Found with an Emscripten -fsanitize=address build (global-buffer-overflow WRITE on netgame). boolean is now unconditionally bool, and spriteframe_t.rotate becomes int (it uses memset(-1) as an uninitialized marker, which a 1-byte bool can't hold). Note: this changes struct layouts, so old savegames/demo recordings are incompatible.
  • -std=gnu99: modern clang defaults to C23, where true/false are keywords and collide with the boolean enum.
  • -fno-strict-aliasing: the id-era code type-puns byte buffers through int16/int32 pointers; don't let -O3 exploit that UB.
  • STACK_SIZE=5MB: Emscripten's default shrank from 5 MB to 64 KB in 3.1.25; Doom's init path overflows the new default.
  • index.html: FS.createPreloadedFileModule.FS_preloadFile, and Module.arguments instead of the no-longer-exported callMain; added handlers so wasm crash stacks reach the console instead of vanishing as unhandled rejections.

Performance

  • Removed debug flags from the release build: SAFE_HEAP=1 (instruments every memory access), STACK_OVERFLOW_CHECK=1, -g/-gsource-map. Added --closure 1 for the JS glue.
  • Stopped Asyncify-sleeping on the frame hot path (src/d_loop.c, src/doom/d_main.c): the rAF main loop (60–120 Hz) outpaces the 35 Hz tic rate, so TryRunTics and the screen-wipe loop hit emscripten_sleep(1) on almost every frame — a full Asyncify stack unwind/rewind each time. They now return to the browser event loop instead; Asyncify stays for startup/netgame paths.
  • Deliberately not -flto: at -O3 it compiles UB in this codebase into an unconditional trap in main() (verified; documented in configure.ac).

Before / after

Both builds use the same source (including the correctness fixes — the original code can't run at all on this toolchain) and the same Emscripten 6.0.2; "before" uses the original flag set, "after" this PR's. Measured on an Apple-Silicon Mac, headless Chrome, shareware doom1.wad.

Metric Before After Δ
Main-thread wasm CPU during demo1 playback (10 s sampling profile) 1007 ms 68 ms ~15x less
Main-thread non-idle time 14.2% 5.6% −61%
-timedemo demo1 (5198 gametics, display-capped) 1985 realtics (91.7 fps) 1814 realtics (100.3 fps) −8.6% wall clock
JS glue size 197,487 B 79,809 B −60%
wasm size 2,361,788 B 2,217,087 B −6%
Source map shipped 1,381,684 B 0 eliminated

The CPU numbers are the meaningful ones: both builds are display-cap-bound on a fast desktop, but the before-build burns ~15x the CPU for the same frames — the gap that shows up as dropped frames and battery drain on phones and slower machines.

Manual Testing

  1. ./scripts/clean.sh && ./scripts/build.sh (brew emscripten 6.0.2 + automake).
  2. Copy shareware doom1.wad into src/, then cd src && python3 -m http.server 8000.
  3. Open http://localhost:8000 — expected: title screen and attract demo render correctly.
  4. Press a key → menu; Enter through New Game → E1M1 starts with the melt wipe; move/turn/strafe — expected: clean rendering, no "Player N left the game" messages, single-player ARMS HUD (not the FRAG counter), smooth input.
  5. Verified end-to-end via CDP-driven Chrome (simulated keyboard input + screenshots) and an -fsanitize=address build running clean.

🤖 Generated with Claude Code

Deployed and playable at https://t-web-latest-zackary-chapple-doom-wasm-doom-wasm-zack-ef80f1-ze.zephyrcloud.app/

zackarychapple and others added 4 commits July 5, 2026 21:14
doomtype.h defined boolean as a 4-byte enum in TUs that had not yet
included <stdbool.h> and as 1-byte bool in TUs that had (via SDL
headers). The same globals (netgame, etc.) were therefore written with
different store sizes depending on the writer's TU; 4-byte stores
clobbered the 3 adjacent bytes, corrupting playeringame[], the
deathmatch flag, and renderer view state (sheared/garbage 3D view,
phantom 'Player 4 left the game' at spawn). Caught with an Emscripten
-fsanitize=address build as a global-buffer-overflow WRITE on netgame.

spriteframe_t.rotate becomes int: R_InstallSpriteLump memsets it to -1
as an uninitialized marker and compares against it, which a 1-byte bool
cannot represent (R_InitSprites aborted with 'frame I has rotations and
a rot=0 lump' once boolean shrank).

Note: struct layout changes make old savegames/demos incompatible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lity

Remove debug-only flags that shipped in the release build and carried a
large runtime cost: SAFE_HEAP=1 (checks every memory access),
STACK_OVERFLOW_CHECK=1, -g/-gsource-map/--source-map-base. Add
--closure 1 to minify the JS glue.

Toolchain compatibility (brew Emscripten 6.0.2 / clang 21):
- -std=gnu99: modern clang defaults to C23 where true/false are
  keywords, colliding with the boolean enum in doomtype.h.
- -fno-strict-aliasing: the id-era code type-puns byte buffers through
  int16/int32 pointers everywhere; do not let -O3 exploit that UB.
- STACK_SIZE=5MB: restores the pre-3.1.25 default; the modern 64KB
  default overflows during init with no checks enabled.
- EXPORTED_RUNTIME_METHODS / INITIAL_MEMORY: renamed settings.
- Do NOT add -flto: at -O3 it compiles UB in this codebase into an
  unconditional trap in main() (verified: crashes right after Z_Init).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rAF-driven main loop (60-120Hz) outpaces the 35Hz tic rate, so the
TryRunTics wait loop and the screen-wipe loop hit I_Sleep(1) ->
emscripten_sleep() on almost every frame. Under Asyncify each such
sleep unwinds and rewinds the entire wasm call stack. Returning to the
browser event loop instead serves the same purpose (the main loop
re-enters next frame) without the per-frame unwind cost. Asyncify
itself stays: startup and netgame paths still block in
emscripten_sleep().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- FS.createPreloadedFile was renamed; use Module.FS_preloadFile.
- callMain is no longer exported; let INVOKE_RUN start main() once
  preloaded files are ready via Module.arguments.
- Surface wasm crash stacks via unhandledrejection/error handlers;
  async-runtime errors were otherwise swallowed without a trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant