Skip to content

Add Emscripten/WebAssembly web port#1141

Draft
zalo wants to merge 133 commits intocoop-deluxe:devfrom
zalo:feature/emscripten-web-port
Draft

Add Emscripten/WebAssembly web port#1141
zalo wants to merge 133 commits intocoop-deluxe:devfrom
zalo:feature/emscripten-web-port

Conversation

@zalo
Copy link

@zalo zalo commented Mar 12, 2026

Addresses #237

Live Deployment: https://zalo.github.io/sm64coopdx/?room=testroom7

Summary

Adds browser support for sm64coopdx via Emscripten compilation to WebAssembly. Build with emmake make TARGET_WEB=1 VERSION=us to produce a .html/.js/.wasm bundle that runs in modern browsers with peer-to-peer multiplayer via PeerJS WebRTC.

Features

  • Full 3D rendering via WebGL 2.0 (Emscripten GL translation)
  • Peer-to-peer multiplayer via PeerJS WebRTC DataChannels (no proxy server needed)
  • Room-based joining: share a ?room=ROOMID URL to play together
  • ROM loading from browser file picker, URL parameter (#rom=URL), or IndexedDB cache
  • Lua mod system compiled to WASM (Lua 5.3.6 as git submodule)
  • DJUI menu system with all 11 languages bundled
  • Correct game speed with 30Hz tick rate and display-rate rendering
  • Intro cutscene skip by default on web
  • GitHub Actions workflow to build and deploy to GitHub Pages

Networking Architecture

Browser A ←──PeerJS WebRTC DataChannel──→ Browser B
         (first to join = host, others = clients)
  • Players share a room ID via URL: ?room=myroom
  • First player to claim the room becomes HOST
  • Subsequent players automatically JOIN as clients
  • Room IDs prefixed with "sm64-" on the PeerJS signalling server
  • Direct peer-to-peer after signalling — works across the internet
  • Host relays client packets via requireServerBroadcast for 3+ players

Known Limitations

  • Audio is silent — sound bank binary data has 64-bit pointer layout from host build tools, causing memory corruption on 32-bit WASM during level music changes. Audio is fully disabled to prevent crashes. Fix requires regenerating sound data with assemble_sound.py --bitwidth 32.

New Files

Path Description
src/pc/web/shell.html HTML shell with ROM picker, PeerJS networking module, overlay UI
src/pc/web/web_main.c/.h IDBFS init, ROM status, stubs for excluded modules
src/pc/web/serve.py No-cache HTTP server for development
src/pc/web/proxy/ Legacy WebSocket-to-UDP proxy (superseded by PeerJS)
src/pc/network/socket/socket_websocket.c/.h PeerJS NetworkSystem backend via EM_JS bridge
src/pc/thread_web.c Single-threaded stubs for pthreads
src/engine/math_util.c Added #include <float.h> for FLT_EPSILON
src/pc/gfx/gfx_opengl.c VAO guard for GLES2/WebGL
src/pc/gfx/gfx_sdl2.c Web-specific: no vsync, no delay, emscripten_webgl_commit_frame
src/pc/utils/misc.c precise_delay_f64 returns immediately on web
tools/convert_sound_32bit.py Removed (audio disabled)
lib/lua/lua-5.3.6 Git submodule → github.com/lua/lua tag v5.3.6
.github/workflows/build-web.yaml CI: build + deploy to GitHub Pages
build_web.sh / serve.sh Build and serve convenience scripts

Modified Files (key changes)

File Change
Makefile TARGET_WEB=1 target, emcc toolchain, Lua from source, -lwebsocket.js removed
src/pc/pc_main.c Web main loop (web_one_iteration), URL params, auto-join/host, audio disabled
src/pc/configfile.c configSkipIntro = true on web
src/pc/platform.c sys_resource_path returns /sm64coopdx on web
src/pc/rom_checker.cpp Graceful directory_iterator for missing MEMFS dirs
src/pc/loading.c emscripten_sleep in ROM/loading loops for ASYNCIFY
src/audio/load.c bank_load_immediate/async return NULL on web
src/pc/network/network.c Early buffer drain, gNetworkPlayerServer->lastReceived fix
src/pc/djui/djui_panel_join_direct.c Room ID input instead of IP:port on web

How to Build & Test

# Install Emscripten SDK, then:
./build_web.sh

# Serve locally:
cd build/us_pc && python3 ../../src/pc/web/serve.py 8083

# Open in browser:
# Solo:  http://localhost:8083/sm64coopdx.html
# Room:  http://localhost:8083/sm64coopdx.html?room=myroom
# +ROM:  http://localhost:8083/sm64coopdx.html?room=myroom#rom=URL

URL Parameters

Parameter Description
?room=ID Join/create a PeerJS room (first visitor = host)
?host=PORT Legacy: auto-host on UDP port (requires proxy)
?join=HOST:PORT Legacy: auto-join via proxy
#rom=URL Download ROM from URL on first visit

Test plan

  • Game compiles to WASM and renders in browser
  • ROM upload from file picker with IndexedDB caching
  • ROM loading via URL hash parameter
  • Language selection with all 11 languages
  • PeerJS room-based multiplayer (host + client see each other)
  • GUI join with room ID input
  • URL auto-join via ?room= parameter
  • Stable connections (no timeout disconnects)
  • Level transitions (castle, wing cap, slide, Bowser gauntlet)
  • GitHub Actions build + Pages deploy
  • 3-player cross-client visibility
  • Audio playback
  • Painting level transitions (Bob-omb Battlefield etc.)
  • Production build optimization

🤖 Generated with Claude Code

zalo and others added 20 commits March 11, 2026 16:12
Adds browser support for sm64coopdx via Emscripten compilation.
Build with `emmake make TARGET_WEB=1 VERSION=us` to produce a
.html/.js/.wasm bundle that runs in modern browsers.

Key changes:

Makefile:
- New TARGET_WEB=1 build option
- Emscripten toolchain (emcc/em++) with USE_SDL=2, USE_ZLIB=1
- WebGL 2.0 via FULL_ES2 + MAX_WEBGL_VERSION=2
- ASYNCIFY for non-blocking main loop
- IDBFS for persistent save/config/mod storage
- Lua compiled from source (no prebuilt .a)
- Excludes Discord SDK, CoopNet, Mumble, update checker

Threading (thread.h, thread_web.c):
- Single-threaded stubs when TARGET_WEB is defined
- Threads execute inline; mutexes are no-ops
- pthread types stubbed as int to avoid header dependency

Main loop (pc_main.c):
- emscripten_set_main_loop() replaces blocking while(true)
- web_one_iteration() callback for browser frame scheduling
- Guards for update checker, Discord, Mumble, Windows console

Networking (socket_websocket.c/.h):
- WebSocket transport replacing raw UDP sockets
- Ring buffer for async packet reception
- emscripten_websocket API for send/receive
- Requires server-side WebSocket-to-UDP proxy for native
  server compatibility

Browser integration (src/pc/web/):
- shell.html: ROM file picker, IndexedDB caching, dark theme,
  audio context resume, loading progress
- web_main.c: IDBFS mount initialization, ROM status tracking,
  save persistence via FS.syncfs()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build fixes:
- Makefile: Guard compiler selection (gcc/clang) to not override
  CC=emcc when TARGET_WEB=1
- Makefile: Set CC_CHECK=emcc so dependency generation uses
  Emscripten (host gcc doesn't understand -s flags)
- Makefile: Add -s USE_ZLIB=1 to BACKEND_CFLAGS (not just LDFLAGS)
  so zlib.h is found during compilation
- Makefile: Exclude socket.c (not just socket_linux/windows.c)
  to avoid duplicate symbols with socket_websocket.c
- Makefile: Exclude thread.c for web (replaced by thread_web.c)
- Makefile: Remove duplicate C_FILES additions (files already
  auto-discovered via SRC_DIRS)
- Makefile: Add -lwebsocket.js to LDFLAGS for WebSocket API
- Makefile: Compile Lua 5.3.6 from source (lib/lua/lua-5.3.6/src/)
  since prebuilt .a is native binary, filter out lua.c/luac.c

Code fixes:
- math_util.c: Add #include <float.h> for FLT_EPSILON (not
  transitively included in Emscripten headers)
- gfx_opengl.c: Guard glGenVertexArrays/glBindVertexArray with
  #ifndef USE_GLES (symbols don't exist in GLES2/WebGL headers)
- thread.h: Revert pthread stub typedefs (Emscripten's sysroot
  already provides pthread_t/pthread_mutex_t)
- rom_checker.cpp: Use error_code overload of directory_iterator
  to avoid crash when directory doesn't exist in MEMFS
- pc_main.c: Create /sm64coopdx directory in MEMFS before fs_init
- pc_main.c: Guard mumble_init and show_update_popup with
  #ifndef TARGET_WEB
- web_main.c: Add stubs for gUpdateMessage, check_for_updates,
  show_update_popup, mumble_init, mumble_update; rewrite EM_ASM
  to EM_JS/emscripten_run_script for Emscripten 3.1.64 compat
- build_web.sh: Build script with Emscripten SDK sourcing

Result: Full WASM build succeeds, HTML shell loads in browser,
WebGL initializes, config files created, ROM picker displayed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The prebuilt liblua53.a files are native binaries that cannot be
linked into WebAssembly. This adds the Lua 5.3.6 source code so
it can be compiled alongside the project when TARGET_WEB=1.

Source: https://www.lua.org/ftp/lua-5.3.6.tar.gz
License: MIT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROM loading flow:
- loading.c: Add emscripten_sleep() to render_rom_setup_screen and
  render_loading_screen loops so ASYNCIFY yields to browser each frame
- loading.c: Re-check filesystem via main_rom_handler() each iteration
  on web so ROM uploaded from JS is discovered automatically
- shell.html: Write ROM to /sm64coopdx/ path matching fs_init, add
  drag-and-drop support, auto-hide overlay after ROM loads

Audio 64-bit/32-bit data fix:
- The sound bank data (sound_data.ctl, .tbl, sequences.bin) is generated
  by build tools using host pointer size (8 bytes on x64). On 32-bit WASM,
  struct fields (ALSeqData.offset, AudioBank pointers) are 4 bytes,
  causing misaligned reads and memory access out of bounds crashes.
- tools/convert_sound_32bit.py: Converts ALSeqFile headers from 64-bit
  to 32-bit pointer layout (shrinks each entry from 16 to 8 bytes)
- Makefile: Run converter on .ctl, .tbl, .bin after decompression for web
- load.c: Skip patch_audio_bank() on TARGET_WEB as bank body data also
  has 64-bit pointer fields. Audio is silent until full 32-bit sound data
  generation is implemented.

Network fix:
- socket_websocket.c: Return early for NT_NONE to prevent constructing
  an invalid WebSocket URL (ws://:7777) that throws a JS exception

Result: SM64 CoopDX renders and runs gameplay in the browser with full
3D graphics, DJUI menus, and game logic. Audio is silent (TODO).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… server

Language files:
- Makefile: Bundle lang/*.ini via --preload-file lang@/sm64coopdx/lang
- platform.c: Return "/sm64coopdx" from sys_resource_path() on web
  (was returning empty string because the non-Windows code path at
  line 337 wasn't patched, only the Windows one at line 255)
- All 11 languages now load correctly in the DJUI language panel

Overlay auto-hide:
- shell.html: Poll WebGL canvas with readPixels to detect actual game
  rendering (non-black pixels), then fade out overlay
- 8-second fallback timeout if pixel detection fails
- Guard against double-hide

Audio bank loading:
- load.c: Return NULL from bank_load_immediate and bank_load_async
  on TARGET_WEB instead of just skipping patch_audio_bank. This
  prevents 64-bit bank body data from being memcpy'd into 32-bit
  structs entirely. All callers already handle NULL gracefully.

WebSocket-to-UDP proxy server (src/pc/web/proxy/):
- proxy.js: Node.js server that bridges WebSocket (browser) to UDP
  (native sm64coopdx server). Each WS connection gets a dedicated
  UDP socket for 1:1 session mapping.
- Configurable target host/port and listen port via CLI args or env
- package.json with ws dependency
- README.md with usage instructions
- .gitignore to exclude node_modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
socket_websocket.c:
- NT_SERVER now supported: connects to proxy with ?host=PORT query
- Proxy opens a UDP listener on that port for native clients
- Host mode packets prefixed with 2-byte LE slot ID for multiplexing:
  - Receive: proxy prepends slot ID identifying the UDP client
  - Send: game prepends destination slot ID (0 = broadcast)
- Slot IDs map directly to localIndex for player tracking
- Client mode uses ?target=HOST:PORT query for proxy forwarding
- Configurable proxy address (default localhost:8765)

proxy.js:
- Complete rewrite with dual-mode support
- HOST MODE (?host=PORT): Opens UDP listener, multiplexes all native
  UDP clients over single WebSocket with 2-byte slot prefix
- CLIENT MODE (?target=HOST:PORT): 1:1 WebSocket-to-UDP forwarding
- Proper cleanup on disconnect, error logging, client tracking

This enables the "Host" button in the WASM build to work through
the proxy server. Native sm64coopdx clients can join by connecting
to the proxy's UDP port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The game was running too fast because emscripten_set_main_loop fires
web_one_iteration on every requestAnimationFrame (~60Hz+), but each
call was running a full 30Hz game tick. This caused the game to run
at 2x+ speed.

Fix: Rewrite web_one_iteration to use proper frame accumulation:
- Use emscripten_get_now() for high-precision timing
- Accumulate elapsed time and only run game ticks when >= 33.3ms
  (30Hz) has passed
- Cap at 2 ticks per rAF call to prevent spiral of death
- Render interpolation frames on every rAF call using the fractional
  delta between the last tick and the next
- Game logic (network, physics, Lua) runs at fixed 30Hz
- Rendering runs at display refresh rate with smooth interpolation

This matches the native build's behavior where produce_one_frame
runs at 30Hz and produce_interpolation_frames_and_delay renders
interpolated frames at the display refresh rate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sound bank binary data (gSoundDataADSR) is generated by host
build tools with 64-bit pointer-sized struct fields. On 32-bit WASM,
AudioBank/Instrument/Drum/AudioBankSample structs have 4-byte pointer
fields instead of 8-byte, causing misaligned reads when raw data is
memcpy'd into structs.

Previous fix returned NULL from bank_load_immediate, making audio
completely silent. This commit replaces that with a runtime converter
that properly loads 64-bit bank data into 32-bit structs.

audio_web.c (new, 721 lines):
- web_patch_bank_64to32(): Reads AudioBank body data at 64-bit field
  offsets and writes into properly-allocated 32-bit structs
- Handles all pointer-containing structs: AudioBank header (drums ptr
  + instruments ptr array), Drum, Instrument, AudioBankSound,
  AudioBankSample
- Copies non-pointer structs (AdsrEnvelope, AdpcmLoop, AdpcmBook)
  by raw byte copy since their layout is identical across archs
- Uses OffsetMap to track 64->32 bit offset remapping for pointer
  patching and deduplication of shared sub-structures
- Combines struct layout conversion AND pointer patching in one pass

load.c changes:
- bank_load_immediate: Calls web_patch_bank_64to32() instead of
  audio_dma_copy_immediate + patch_audio_bank on TARGET_WEB
- bank_load_async: Performs synchronous loading via the converter
  on TARGET_WEB (PC DMA is already memcpy, so async is moot)
- Removes previous NULL-return early exits

Audio should now play through Web Audio API via Emscripten's SDL2
audio backend (ScriptProcessorNode / AudioWorklet).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The proxy URL was hardcoded to ws://localhost:8765 which breaks when
accessing the game from another machine on the network (e.g.
http://10.0.0.76:8083). Now uses window.location.hostname via
emscripten_run_script_string to construct the WebSocket URL with
the same host the page was loaded from.

Also adds serve.sh convenience script to start both HTTP and proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
proxy.js:
- Client mode: detect IPv4 vs IPv6 target, use udp4 for IPv4 addresses
  (udp6 socket send to raw IPv4 caused EINVAL)
- Host mode: use udp4 and bind to 0.0.0.0 instead of :: (udp4 can't
  bind to IPv6 any-address)
- Add verbose logging for packet flow debugging (WS->UDP, UDP->WS,
  close reasons, error details)

socket_websocket.c:
- Use window.location.hostname for proxy URL instead of hardcoded
  "localhost", so connecting from other devices on the LAN works

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- configfile.c: Default configSkipIntro to true on TARGET_WEB to avoid
  network timeouts during the unskippable intro cutscene
- proxy.js: Fix host mode UDP bind from "::" to "0.0.0.0" for udp4,
  add verbose packet flow logging for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The network_update function checks player timeouts (lastReceived)
BEFORE calling gNetworkSystem->update() which processes incoming
packets. On native UDP this is fine since recvfrom is synchronous.
On web, WebSocket messages arrive asynchronously between game ticks
and are buffered in a ring buffer. If the buffer isn't drained
before the timeout check, lastReceived stays stale and players
get disconnected after ~22 seconds.

Fix: On TARGET_WEB, call gNetworkSystem->update() at the start of
network_update to drain buffered WebSocket messages before any
timeout checks run. This ensures lastReceived is current.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
emscripten_set_main_loop was called with simulate_infinite_loop=0,
which means main() returns immediately after setting up the callback.
When main() returns, Emscripten's runtime performs cleanup including
closing WebSocket connections, causing multiplayer disconnects after
~30-60 seconds.

Fix: Set simulate_infinite_loop=1 so main() never returns and the
runtime doesn't clean up resources. The game loop runs entirely via
the web_one_iteration callback.

Also adds:
- Cache-busting locateFile in shell.html (appends ?v=timestamp)
- No-cache HTTP server (serve.py) for development
- Debug logging in network_shutdown and socket_shutdown

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: On the web client, packets from the server arrive via
WebSocket with localIndex=UNKNOWN_LOCAL_INDEX. The network_receive
function only updates lastReceived for localIndex > 0, so the
server player's lastReceived was never updated. After 22.5 seconds
the timeout check fires and disconnects.

Fix: In network_receive on TARGET_WEB, always update
gNetworkPlayerServer->lastReceived when in NT_CLIENT mode,
regardless of localIndex.

URL auto-join/host:
- Parse ?join=HOST:PORT and ?host=PORT from URL search params
- Read params at main() startup via EM_ASM (before ASYNCIFY)
- Execute auto-network in web_one_iteration callback (after
  gGameInited=true) since ASYNCIFY skips code in main()
- Enables Playwright-based automated multiplayer testing

Also fixes:
- Use simulate_infinite_loop=0 with EXIT_RUNTIME=0 (ASYNCIFY
  conflicts with simulate_infinite_loop=1)
- Convert LOG_INFO to printf for web debug (LOG_INFO is no-op
  unless configDebugInfo is set)
- No-cache HTTP server and locateFile cache-busting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Level transitions (entering Bob-omb Battlefield etc.) froze because
the custom web_one_iteration bypassed the normal produce_one_frame
flow. During level loads, the game internally calls
produce_one_dummy_frame which uses SDL_Delay (emscripten_sleep via
ASYNCIFY). The custom loop called game_loop_one_iteration directly,
which didn't support ASYNCIFY stack unwinding from rAF callbacks.

Fix: Use WAPI.main_loop(produce_one_frame) in web_one_iteration,
gated to 30Hz. This preserves:
- Smooth interpolation (produce_interpolation_frames_and_delay
  renders multiple frames per tick at display refresh rate)
- Proper SDL event handling (handle_events in gfx_sdl_main_loop)
- Level transition loading screens (produce_one_dummy_frame)
- ASYNCIFY sleep during delays (SDL_Delay → emscripten_sleep)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: Multiple blocking paths in the render/delay pipeline
caused infinite loops or ASYNCIFY conflicts on web:

1. produce_interpolation_frames_and_delay had a do-while render loop
   that spun endlessly when delays were disabled (no sleep = curTime
   never advances past targetTime)
2. SDL_GL_SetSwapInterval triggered emscripten_sleep via ASYNCIFY,
   conflicting with emscripten_set_main_loop callback
3. precise_delay_f64 busy-waited on the main thread
4. gfx_sdl_delay (SDL_Delay) used emscripten_sleep

Fixes:
- produce_interpolation_frames_and_delay: render exactly 1 frame
  on web, return immediately (rAF loop handles frame pacing)
- gfx_sdl_set_vsync: don't touch swap interval on web
- gfx_sdl_delay: no-op on web
- precise_delay_f64: return immediately on web
- produce_one_dummy_frame: skip WAPI.delay on web
- level_script_execute: safety break after 50K iterations on web
- Add rAF exception catching and stall detection in shell.html
- Add URL auto-join/host (?join=HOST:PORT, ?host=PORT)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The crash when entering levels via paintings (e.g. Bob-omb Battlefield)
and collecting stars was caused by the audio bank converter
(web_patch_bank_64to32) producing corrupt pointers. When the game
loaded new music for a level, the audio system would access invalid
memory, causing:
  - HandleAudioProcess: memory access out of bounds (SDL2 audio callback)
  - web_one_iteration: memory access out of bounds (buffer_audio in game tick)

These errors spammed every frame since the rAF loop kept running
but the WASM memory was corrupted.

Fix: Completely disable audio on web:
  - Force audio_api = audio_null (prevents SDL2 audio from initializing
    and creating the ScriptProcessorNode)
  - Skip buffer_audio in web_one_iteration game tick
  - Also: disable SDL_GL_SwapWindow (use emscripten_webgl_commit_frame),
    disable SDL_Delay and precise_delay_f64, disable vsync swap interval
  - Add 120 stars for testing, level script timeout safety

Levels that work: Castle Grounds, Wing Cap, Bowser gauntlet, slide
Levels that previously crashed: Bob-omb Battlefield (painting levels)
All levels should now work since audio is fully disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Supports loading ROM from a URL via hash or query parameter:
  #rom=https://example.com/rom.z64
  ?rom=https://example.com/rom.z64

Features:
- Streaming download with progress display
- Automatic byteswap for .n64 and .v64 formats
- Validates ROM magic bytes after download
- Caches to IndexedDB for subsequent visits
- Falls back to file picker if download fails
- Can combine with other params: ?host=7777#rom=URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove 112 lines of temporary debug logging and diagnostic code
that was added while investigating the level transition freeze
(which turned out to be caused by corrupt audio bank pointers).

Removed:
- pc_main.c: EM_ASM watchdog timer, iteration counter logging,
  produce_one_dummy_frame counter, gWebAbortFlag
- game_init.c: WEB_CHECK_TIMEOUT macro and all 5 call sites,
  clock_t timing, time.h include
- level_script.c: emscripten_get_now timeout checker, emscripten.h
  include, gWebAbortFlag extern
- network_player.c: printf timeout debug logging
- network.c: printf network_shutdown debug logging
- socket_websocket.c: printf debug lines, restored LOG_INFO/LOG_ERROR
- shell.html: setTimeout interceptor, reduced stall check frequency

Kept all functional code: audio disable, buffer drain, lastReceived
fix, delay/vsync skips, rAF error catching, WebGL context handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove 120-star grant from web_auto_network (was for testing)
- Convert printf debug logs to LOG_INFO in web_auto_network
- Keep [WEB BUILD] timestamp printf (useful one-time startup info)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zalo
Copy link
Author

zalo commented Mar 12, 2026

For what it's worth, it runs really well (save for that there's no audio). The next big thing I'm trying to figure out is the right way to do networking. I like the P2P-first philosophy of the main repo, but the P2P solution in browsers (WebRTC) needs a signaling server (which can probably be rolled into CoopNet?). I'm going to move in a slightly different direction for my own fork, with peerjs to specify rooms (via query parameter in the URL).

Perhaps the full solution is to add WebRTC support to CoopNet AND the native clients, so that they can all talk to each other seamlessly...

EDIT: Networking now works great via peerjs

zalo and others added 9 commits March 11, 2026 23:32
Audio cleanup:
- Remove audio_web.c/h (broken 64→32 bank converter that caused
  memory corruption during level transitions)
- Remove convert_sound_32bit.py (ALSeqFile header converter, unused
  now that audio is fully disabled on web)
- Remove clean_sound_rebuild.sh (helper for converter)
- Simplify Makefile sound rules back to plain decompress (no converter)
- Simplify load.c: bank_load_immediate and bank_load_async just
  return NULL on TARGET_WEB with a clear comment explaining why
- Remove stale #ifdef TARGET_WEB blocks and #endif comments

Audio is completely disabled on web (null audio API, no buffer_audio)
because the sound bank binary data has 64-bit pointer-sized struct
fields from the host build tools. Enabling audio requires regenerating
the sound data with assemble_sound.py --bitwidth 32.

Lua submodule:
- Replace vendored lib/lua/lua-5.3.6/ (74 files, 1.2MB) with a git
  submodule pointing to github.com/lua/lua at tag v5.3.6
- Update Makefile paths (GitHub repo has sources at root, not src/)
- Dramatically reduces PR diff size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Builds the Emscripten/WASM web port and deploys to GitHub Pages.
Triggers on pushes to main and feature/emscripten-web-port branches,
plus manual workflow_dispatch.

The workflow:
1. Checks out with submodules (for Lua 5.3.6)
2. Installs build dependencies and Emscripten SDK 3.1.64
3. Builds with emmake make TARGET_WEB=1
4. Copies build output to _site/ (renames .html to index.html)
5. Deploys to GitHub Pages via actions/deploy-pages

The deployed page will be available at:
  https://zalo.github.io/sm64coopdx/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two caches to speed up subsequent builds:
- Emscripten system libraries (~/.emscripten_cache): SDL2, zlib,
  and other ports that get compiled on first use (~30s savings)
- Build object files (build/us_pc): incremental builds only
  recompile changed files. Cache key hashes all source dirs so
  it invalidates on any code change, with a fallback restore-key
  that reuses the most recent cache for partial rebuilds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the WebSocket-to-UDP proxy architecture with direct
peer-to-peer WebRTC connections via PeerJS. No proxy server needed.

How it works:
- Players share a room ID (via ?room=ROOMID URL parameter)
- First player to claim the room becomes HOST
- Subsequent players automatically JOIN as clients
- Room IDs are prefixed with "sm64-" on the PeerJS signalling server
- Uses reliable binary DataChannels for game packets

Architecture change:
  Before: Browser → WebSocket → Proxy → UDP → Native Client
  After:  Browser → PeerJS (WebRTC DataChannel) → Browser

New URL parameter:
  ?room=myroom  — Join or host room "myroom"
  Combines with existing: ?room=myroom#rom=URL

shell.html:
- Added PeerJS CDN (peerjs@1.5.4)
- Added PeerNetwork module: init, host/client auto-detection,
  slot-based multiplexing, binary DataChannel send/recv
- Added room ID input in overlay UI

socket_websocket.c:
- Replaced Emscripten WebSocket APIs with EM_JS PeerJS bridge
- peer_init, peer_send, peer_drain_recv, peer_shutdown EM_JS functions
- Ring buffer now filled from PeerNetwork.drainRecvBuffer() via EM_JS
- New format: [u16 slotId][u16 len][data] per packet in ring buffer
- Host/client mode auto-detected from PeerJS state

pc_main.c:
- Added sWebRoomParam for ?room= URL parameter
- web_auto_network handles ?room= (priority: join > host > room)

Makefile:
- Removed -lwebsocket.js (no longer using Emscripten WebSocket API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PeerJS connections are async — the C code was sending the mod list
request immediately in ns_socket_initialize before PeerJS had
actually connected, causing the packet to be silently dropped.

Fix: Track connection state in ns_socket_update. When PeerJS
transitions from disconnected to connected:
- If we're a client: send mod list request and switch to NT_CLIENT
- If we're the host: already handled by djui_panel_do_host

Also removed premature djui_connect_menu_open and
network_send_mod_list_request from ns_socket_initialize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ?room= flow starts as NT_SERVER (host) via djui_panel_do_host.
When PeerJS determines we're actually a client (room already taken),
ns_socket_update now:
- Switches gNetworkType from NT_SERVER to NT_CLIENT
- Opens the "Joining..." connect menu
- Sends the mod list request handshake

This matches the behavior of the manual GUI join flow which the user
confirmed works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When using ?room=ROOMID, the first player becomes host via
djui_panel_do_host. The second player initially starts as host too,
but PeerJS detects the room is taken and falls back to client mode.

The ns_socket_update function now handles this transition:
- Detects when PeerJS reports we're a client (sIsHostMode=false)
- Resets gNetworkType from NT_SERVER to NT_CLIENT
- Sends mod list request to initiate the join handshake
- Host continues processing normally

Verified with Playwright: both host and client tabs reach gameplay
at Castle Grounds via the same ?room= URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The client wasn't seeing the host player because the server→client
transition only changed gNetworkType without properly initializing
client-side network state (player list, sync objects, etc).

Fix: When PeerJS detects we're a client:
1. Set sPreservePeerConnection flag to keep PeerJS alive
2. Call network_shutdown (resets C state, skips peer_shutdown)
3. Call network_init(NT_CLIENT) for full client initialization
4. ns_socket_initialize skips peer_init if already connected

Also: ns_socket_initialize now checks peer_is_connected() before
calling peer_init to avoid double-initialization during the
server→client transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous approach (start as NT_SERVER, transition to NT_CLIENT) caused
an infinite reinit loop because network_shutdown+network_init inside
ns_socket_update reset the role detection flags.

New approach: always start as NT_CLIENT via the ?room= path.
PeerJS still tries to register as host first:
- If room is free: PeerJS becomes host, ns_socket_update detects this
  and does a clean transition to NT_SERVER via djui_panel_do_host
  (with sPreservePeerConnection to keep the PeerJS connection)
- If room is taken: PeerJS becomes client, ns_socket_update sends
  the mod list request for a normal join handshake

This matches the GUI join flow which was confirmed working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zalo zalo changed the title Add Emscripten/WebAssembly web port (TARGET_WEB=1) Add Emscripten/WebAssembly web port Mar 14, 2026
zalo and others added 29 commits March 14, 2026 08:27
Implement a web room discovery system that reuses the existing DJUI
server browser UI (same as CoopNet on native), backed by a Cloudflare
Worker + Durable Object for ephemeral room storage.

- New CF Worker project (src/pc/web/lobby-server/) with DO RoomRegistry
  holding rooms in-memory with 30s heartbeat TTL and unlisted filtering
- New C backend (web_lobby.c) bridges DJUI callbacks to the Worker API
  via EM_JS fetch calls, matching the CoopNet query callback signature
- djui_panel_join_lobbies.c extended from COOPNET-only to also compile
  under TARGET_WEB, with room click navigating to ?room=ROOMID
- Main menu now shows HOST + JOIN on web (matching native layout)
- JOIN menu offers ROOMS (lobby browser) and DIRECT (room ID input)
- Host panel gains "Unlisted" toggle persisted to localStorage
- Host auto-registers with lobby server after PeerJS confirms host role
- beforeunload sends sendBeacon unregister for reliable cleanup
- Exported 8 new WASM functions for lobby metadata and lifecycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update all placeholder URLs to the live deployment at
sm64coopdx-lobby.makeshifted.workers.dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stable build deploys to root (/), audio branch deploys to /audio/.
Both branches build in parallel and are combined in the deploy step,
so neither overwrites the other.

- zalo.github.io/sm64coopdx/ = stable (feature/emscripten-web-port)
- zalo.github.io/sm64coopdx/audio/ = audio experiment (feature/web-audio)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stage build outputs into flat directories before uploading so artifact
structure is predictable in the deploy job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guard all SSGI calls (init, start_frame, render, composite,
set_perspective, set_projection_matrix) with #ifndef TARGET_WEB.
SSGI uses GL features that generate WebGL error spam (INVALID_ENUM,
INVALID_VALUE on FBO/texture creation), which tanks performance
when Chrome DevTools is open due to error serialization overhead.

Also hide SSAO controls from DJUI Display Options on web since
the feature is disabled.

Add temporary per-section frame profiler to web_one_iteration()
that logs avg ms per tick/render section every 5 seconds, useful
for diagnosing future performance issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove TARGET_WEB guards that disabled audio: bank_load_immediate/async
early returns, forced audio_null, skipped buffer_audio and mumble_init.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The pre-compressed sound data was generated on a 64-bit host, using
8-byte pointers in binary structs (ALSeqFile, AudioBank, Instrument,
etc.). WASM is 32-bit, so these structs expect 4-byte pointers. This
caused memory access violations in patch_audio_bank on level load.

Fix: generate 32-bit little-endian sound data in sound/web/ by
extracting samples from ROM via disassemble_sound.py, re-encoding
with vadpcm_enc, and running assemble_sound.py --bitwidth 32
--endian little. TBL and sequences use header-only conversion via
convert_seqfile_32.py (data blobs are pointer-layout-independent).

Makefile uses sound/web/ sources when TARGET_WEB=1. Sound effects
now play correctly. Music sequences do not play yet (next step).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sequences (except seq 0) are loaded from ROM at runtime via
ROM_ASSET_LOAD_SEQUENCE into gMusicData at offsets from
sequences_offsets.h. These offsets were for the 64-bit ALSeqFile
layout. The 32-bit header is smaller (0x120 vs 0x240 data start),
so sequence data was being written past the valid data region,
causing the sequence player to read garbage and immediately end.

Fix: add sound/web/sequences_offsets.h with delta-adjusted offsets,
conditionally included by sequences_assets.c for TARGET_WEB builds.
Also remove debug printf statements from the investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The assemble_sound.py approach re-encoded AIFC samples, producing CTL
data with different codebook/loop parameters than the original TBL.
This caused pitch-shifted chain chomp bark, loud rumbling on BoB,
and missing instruments (14 vs 15 in bank 7).

The new convert_ctl_32.py structurally walks each bank's 64-bit data,
converting P (8->4 byte) and X (4->0 byte) fields while remapping all
internal pointer offsets. This preserves the exact original bank data
(ADSR envelopes, tuning, instrument counts) while fixing the pointer
layout for WASM's 32-bit structs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audio is now merged into the main emscripten-web-port branch, so the
separate feature/web-audio build job and /audio/ deploy path are no
longer needed. Updated CLAUDE.md to document the structural CTL
converter approach (convert_ctl_32.py) and warn against using
assemble_sound.py regeneration which causes audio artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Re-enable SSGI/SSAO on web builds by removing all #ifndef TARGET_WEB
  guards around ssgi_init, ssgi_start_frame, ssgi_render, ssgi_composite,
  ssgi_set_perspective, and the DJUI display panel SSAO controls.
  The SSGI system already handles WebGL gracefully (disables itself
  if shaders fail to compile).

- Add "Add Stars" cheat to mods/cheats.lua that progressively adds
  one star per second to courses while enabled.

- Fix iOS Safari audio by adding touchstart/touchend event listeners
  for AudioContext resume (iOS requires touch events, not just click).
  Also adds webkitAudioContext fallback and retry logic for cases
  where SDL2's AudioContext hasn't been created yet on first touch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Try 4x MSAA with 24-bit depth on WebGL. If context creation fails
(common on mobile GPUs like Mali/PowerVR), automatically retry with
no MSAA and 16-bit depth. This gives desktop browsers smooth edges
while keeping Android compatibility.

SSGI/SSAO remains disabled by default (gSSGI_Enabled=0) as intended.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…bby CORS

- Remove non-functional "Add Stars" cheat from cheats.lua
- Gate PeerJS debug logs behind PEERJS_DEBUG flag (set in console to enable)
- Auto-generate room IDs (sm-XXXXXX) when no ?room= param is provided,
  and update the URL so the link can be shared
- Persist save files to IndexedDB (more reliable than localStorage which
  gets cleared by browsers). Falls back to localStorage for migration.
- Generate 100% completion save file when no save exists
- Open lobby CORS to all origins (public game lobby, no need to restrict)
- Lobby API confirmed working (register/query/heartbeat all functional)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Visiting https://sm64coopdx-lobby.makeshifted.workers.dev/ now shows
a minimalist dark-themed page listing all active rooms with their
host name, player count, version, mods, and age. Auto-refreshes
every 10 seconds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
web_lobby_on_room() was declared with uint64_t lobbyId but called
from JS via ccall with type 'number' (32-bit). On WASM, uint64_t
is passed as two 32-bit values, so all subsequent parameters
(connections, maxConnections, version, hostName, description) were
shifted and read as garbage. Changed to int params matching JS
number type, cast to uint64_t/uint16_t for the C callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PWA:
- manifest.json with fullscreen display mode and app icons
- Service worker for install prompt and network-first caching
- App icons at 120/152/180/192/512px generated from res/NewIcon.png
- Favicon set to 192px icon, apple-touch-icon to 180px
- CI copies PWA assets to GitHub Pages deployment

iOS fullscreen:
- viewport-fit=cover extends canvas into safe area insets
- apple-mobile-web-app-capable for home screen app mode
- black-translucent status bar style eliminates top bar
- 100dvh canvas height for true fullscreen on iOS Safari
- Background color matched to #0a0e1a

Touch controls - closest button matching:
- Single overlay listener on touch-gamepad div catches all button touches
- Each touch finds the nearest button by center distance, even if the
  touch didn't land directly on a button element
- Prevents missed presses from imprecise finger placement

Touch controls - anchor-based joystick:
- Initial touch position becomes the joystick anchor point
- Visual base moves to center on touch location
- All stick movement is relative delta from anchor
- Enables fine-grained control without needing to find the stick
- Stick zone expanded to 35vw x 60vh (entire lower-left area)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lerts

- Fix touch controls blocking DJUI: move button listeners to canvas
  instead of the gamepad overlay. Buttons only capture touches within
  80px of a button center; all other touches pass through to DJUI.
  The gamepad overlay remains pointer-events:none.

- Simplify service worker to pass-through only (no caching). This
  prevents interference with PeerJS signaling, lobby API, and
  WebSocket connections. SW exists purely for PWA install criteria.

- Auto-generate sm-XXXXXX room IDs in the C main() EM_ASM block
  when no ?room= param is provided. Updates URL via replaceState
  so the link can be shared before PeerJS starts.

- Add join failure alerts: shows room name and error details when
  PeerJS connection fails (peer-unavailable = room not found) or
  max reconnect attempts are exhausted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GitHub Pages deploys the HTML as index.html, so the PWA start_url
pointing to sm64coopdx.html caused 404s when launching from home screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove auto room ID generation from main() startup — no longer
  injects ?room= into URL on every page load. Users can now reach
  the home screen / title screen without being forced into a room.

- Move random room ID generation (sm-XXXXXX) to the DJUI host panel
  where it pre-fills the Room ID input field. Users can edit it or
  accept the default before clicking PLAY.

- Add canvas resize handler for orientation changes. Updates the
  canvas backing buffer (width/height attributes) to match the new
  viewport dimensions on resize and orientationchange events.
  Includes a 200ms delay for orientationchange to let the browser
  settle the viewport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Revert closest-button matching and anchor-based joystick back to
the original per-button touch listeners and base-centered stick.
The experimental touch changes caused usability regressions.

Fix canvas sizing: remove the resize handler that was fighting with
Emscripten's SDL2 internal canvas management (caused the game to
render at a fraction of the display size). Remove safe-area padding
on body and 100dvh on canvas that caused layout issues. Emscripten
handles canvas sizing correctly on its own.

Touch controls are now identical to the pre-PWA state. DJUI elements
and text boxes are clickable again since the gamepad overlay is back
to pointer-events:none with only individual buttons being interactive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore viewport to original 'width=device-width, initial-scale=1.0'
without viewport-fit=cover or dynamic switching. The switching caused
touch coordinate offsets on the gamepad overlay in both orientations.

Remove canvas size override in gfx_sdl2.c (Emscripten manages this).

Document in CLAUDE.md: viewport-fit=cover gives great fullscreen but
switching it dynamically breaks touch mapping. A permanent cover mode
would work if gamepad positioning accounts for safe-area-inset-* CSS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Middle click (mouse button 2) now triggers Z trigger by default,
  completing left=B(punch), right=A(jump), middle=Z

- Direct join panel on web now calls PeerNetwork.init() directly
  instead of page-reload via window.location.href, which failed
  silently when the URL was already set to a different room.
  Updates URL via replaceState so the link is shareable.

- Revert viewport-fit/canvas-resize experiments back to original
  'width=device-width, initial-scale=1.0' — dynamic switching
  caused touch offsets on the gamepad overlay

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The free 0.peerjs.com server has strict rate limits causing
'WebSocket closed before connection established' errors.

Added a PeerJS-compatible WebRTC signaling server inside the existing
RoomRegistry Durable Object at /peerjs. The DO routes OFFER/ANSWER/
CANDIDATE messages between peers using Cloudflare's hibernatable
WebSocket API. Handles ID collisions (page refresh) by replacing
the existing WebSocket.

PeerJS clients now point to sm64coopdx-lobby.makeshifted.workers.dev
instead of 0.peerjs.com. The PEERJS_CONFIG is overridable via
window.PEERJS_CONFIG before the WASM loads.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ectly

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The DO-based PeerJS server broke peer-to-peer connections.
Reverting to the original 0.peerjs.com setup.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Two bugs caused the host to break after rejecting an over-capacity join:

1. nextSlotId incremented forever — after ~7 disconnects+reconnects it
   would exceed MAX_PLAYERS (16), making all new slots map to
   UNKNOWN_LOCAL_INDEX in C, silently dropping all future joins.

2. Rejected connections stayed open — the 9th+ player kept sending
   packets into the ring buffer with out-of-range slot IDs, corrupting
   the host's game state.

Fixes:
- Replace nextSlotId++ with _allocSlot() that reuses freed slots 1-15
- Reject connections immediately (conn.close()) when all slots are full
- Add peer_kick_slot() EM_JS to close a WebRTC connection by slot ID
- Call peer_kick_slot() from ns_socket_clear_id() so the WebRTC
  connection closes when the game layer disconnects a player

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
All connection events now log with [Net] prefix (always visible,
not gated behind PEERJS_DEBUG):

HOST logs:
- [Net] HOST ready — room/peerId/slot count
- [Net] HOST signaling error — type + message
- [Net] HOST lost signaling server — client count + reconnecting
- [Net] HOST peer destroyed
- [Net] HOST incoming — peer/slot/capacity
- [Net] HOST rejected — peer/reason (full)
- [Net] HOST client CONNECTED — slot/peer/capacity
- [Net] HOST client DISCONNECTED — slot/peer/remaining
- [Net] HOST connection error — slot/peer/error
- [Net] HOST kicking slot — game-layer disconnect

CLIENT logs:
- [Net] CLIENT peer ready — connecting to room
- [Net] CLIENT lost signaling server — reconnecting
- [Net] CLIENT peer error — type/message
- [Net] CLIENT peer destroyed
- [Net] CLIENT connected to host — room
- [Net] CLIENT disconnected — scheduling reconnect
- [Net] CLIENT connection error — type/room
- [Net] CLIENT reconnect attempt — N/max/delay/room
- [Net] CLIENT gave up reconnecting

Co-Authored-By: Claude Sonnet 4.6 (1M context) <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