Add Emscripten/WebAssembly web port#1141
Draft
zalo wants to merge 133 commits intocoop-deluxe:devfrom
Draft
Conversation
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>
Author
|
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 |
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=usto produce a.html/.js/.wasmbundle that runs in modern browsers with peer-to-peer multiplayer via PeerJS WebRTC.Features
?room=ROOMIDURL to play together#rom=URL), or IndexedDB cacheNetworking Architecture
?room=myroomrequireServerBroadcastfor 3+ playersKnown Limitations
assemble_sound.py --bitwidth 32.New Files
src/pc/web/shell.htmlsrc/pc/web/web_main.c/.hsrc/pc/web/serve.pysrc/pc/web/proxy/src/pc/network/socket/socket_websocket.c/.hsrc/pc/thread_web.csrc/engine/math_util.c#include <float.h>forFLT_EPSILONsrc/pc/gfx/gfx_opengl.csrc/pc/gfx/gfx_sdl2.cemscripten_webgl_commit_framesrc/pc/utils/misc.cprecise_delay_f64returns immediately on webtools/convert_sound_32bit.pylib/lua/lua-5.3.6github.com/lua/luatag v5.3.6.github/workflows/build-web.yamlbuild_web.sh/serve.shModified Files (key changes)
MakefileTARGET_WEB=1target, emcc toolchain, Lua from source,-lwebsocket.jsremovedsrc/pc/pc_main.cweb_one_iteration), URL params, auto-join/host, audio disabledsrc/pc/configfile.cconfigSkipIntro = trueon websrc/pc/platform.csys_resource_pathreturns/sm64coopdxon websrc/pc/rom_checker.cppdirectory_iteratorfor missing MEMFS dirssrc/pc/loading.cemscripten_sleepin ROM/loading loops for ASYNCIFYsrc/audio/load.cbank_load_immediate/asyncreturn NULL on websrc/pc/network/network.cgNetworkPlayerServer->lastReceivedfixsrc/pc/djui/djui_panel_join_direct.cHow to Build & Test
URL Parameters
?room=ID?host=PORT?join=HOST:PORT#rom=URLTest plan
🤖 Generated with Claude Code