Engineering TH08 Web

September 11, 2026 · View on GitHub

This document explains how TH08 Web was taken from a reconstructed Win32-era C++ codebase to a public, source-built browser release. It follows the work in the order that made each unknown independently testable: compile first, define the browser boundaries, reach a playable frame, remove the temporary hot-path compatibility code, restore runtime correctness, and only then publish.

For the current system contract and the full verification record, see Web port architecture and status. This document focuses on the engineering journey and the decisions behind the final design.

1. Definition of done

The product goal was deliberately concrete:

Open a page, select th08.dat and thbgm.dat from a legal local TH08 installation, and play Imperishable Night in the browser.

That sentence established five non-negotiable constraints.

ConstraintConsequence
Use the reconstructed sourceGameplay remains in C++; JavaScript is only a browser boundary.
Do not distribute retail dataDAT files must come from the local file picker and must never enter the deployment artifact.
Preserve the existing engine shapeWin32, DirectInput, DirectSound, and D3D8 calls need compatibility surfaces rather than a rewritten game.
Keep bullet-hell timing playableThe browser event loop, renderer, and input path must sustain stable authored frame progression.
Publish as a normal static siteThe result must be HTML, JavaScript, Wasm, and project-owned metadata, with no application server or uploaded game data.

The Web target is a modern port from the reconstructed sources. It does not claim that the Wasm output matches the original x86 executable byte-for-byte. The reconstruction and the browser product share authored C++ behavior but have different compilers, ABIs, platform code, and distribution artifacts.

2. Choosing the architecture

Three broad approaches were possible.

Rewrite the game for a browser framework

A JavaScript or TypeScript recreation could have produced a Web-native codebase, but it would create a second gameplay implementation. Every movement rule, enemy opcode, spell condition, score update, and animation quirk would then need to be rediscovered and kept in sync.

Run the original x86 executable in an emulator

An emulator could preserve the retail program, but it would make the original executable part of the runtime requirement, retain the old graphics and system boundaries, and move the project away from its source-reconstruction advantage.

Compile the reconstructed C++ to WebAssembly

Emscripten could compile the existing 32-bit-oriented C++ while providing SDL, WebGL, Web Audio, pthread, and filesystem integration. The browser-specific work could stay at the same seams already used by the modern compatibility layer.

This third option was selected. The final ownership boundary is:

flowchart LR
    subgraph Page[Browser main thread]
        Picker[Local file picker]
        Keys[Keyboard events]
        Audio[Web Audio device]
        Saves[IndexedDB / IDBFS]
        Present[Canvas presentation]
    end

    subgraph Worker[Pthread worker]
        Game[Authored C++ game]
        Win32[Win32-shaped compatibility API]
        D3D[D3D8-shaped renderer]
        Mixer[Authored-shaped audio mixer]
    end

    Game --> Win32
    Game --> D3D
    Game --> Mixer
    Picker -->|th08.dat copy| Worker
    Picker <-->|thbgm.dat range reads| Win32
    Keys -->|shared atomic state| Win32
    Mixer <-->|small main-thread proxies| Audio
    Win32 <-->|allowlisted save paths| Saves
    D3D -->|WebGL 2 / ImageBitmap| Present

The main thread owns browser-only APIs. A pthread worker owns the authored game loop and can block at compatibility boundaries without freezing the page.

TH08 Web launcher before any retail files are selected

3. Milestone sequence

The port was developed as a series of independently testable gates.

CheckpointWhat it proved
78b3157All shared authored translation units can compile as wasm32; focused layout, data, and renderer probes are runnable.
bb4d83fThe full game links, accepts local DAT files, reaches title and gameplay, produces audio, and receives keyboard input.
3f38ecbThe temporary legacy GL path is replaced by a direct WebGL 2 renderer and Wasm runtime ownership aliases are restored.
8fa575fBrowser saves persist without allowing either retail archive into IndexedDB.
1e5ff3aFirefox receives an explicit frame-presentation path while Chromium keeps direct canvas composition.
6e1b202The static artifact has an exact allowlist and carries Cloudflare isolation metadata.
aca272d, 6333447A validated main build deploys automatically through Node 24 and a pinned Wrangler release.

The important property of this order is that a failure at one gate had a small search space. A compiler error was not mixed with file I/O; a black frame was not mixed with stage simulation; a late-route score bug was not blamed on the browser compositor.

4. Prove compiler portability before linking

The first CMake target was not the game executable. The th08-web-authored-compile object library compiled all 44 shared game and PBG translation units as wasm32 without linking a browser runtime.

This answered the cheapest decisive question: can Emscripten parse and compile the reconstructed C++98/MSVC-shaped source at all?

The gate uses:

  • C++98 mode, matching the source language level;
  • -fms-extensions for required Microsoft syntax;
  • the modern Win32 compatibility headers before system headers;
  • a small forced include, web_compat.hpp, for Web-only compiler boundary definitions;
  • -O2, so the same source shapes are exercised under the optimizer used by the release target.

Only after every authored object compiled did the full th08-web executable add the SDL, renderer, filesystem, browser shell, and pthread pieces.

Focused probes

Small experiments were used before the full game could provide useful signals:

  • scripts/build-web-layout-probe.sh checks the layout assumptions that matter to the 32-bit source model;
  • scripts/build-web-data-probe.sh proves local File selection and first/last byte access without uploading a DAT;
  • scripts/build-web-renderer-probe.sh exercises the graphics boundary without requiring a complete stage;
  • scripts/build-web-probe.sh provides a small general Wasm bring-up gate.

These probes remain useful regression tools because they separate browser API availability from game-state correctness.

5. Turn WinMain into a browser lifecycle

The original outer loop blocks until the process exits. A browser tab cannot own its event loop that way: it must yield so input, promises, audio, and canvas composition can progress.

The Web branch keeps the authored frame body but replaces the outer while with one Th08WebMainLoop call per Emscripten main-loop callback:

browser schedules callback
  -> process one queued Win32-shaped message
  -> test the D3D8-shaped device
  -> run one authored Render() frame
  -> return to the browser compositor

Returning after each frame is the presentation and scheduling boundary. An early blocking-loop prototype produced valid pixels in the WebGL framebuffer but a black page because the browser never received the yield it needed to compose the worker canvas.

The executable is linked with -sINVOKE_RUN=0. The launcher waits until both local files are selected and persistent storage is restored, then calls Module.callMain([]). This also places startup after a user gesture, which is important for browser audio policy.

6. Put the game on a pthread worker

-sPROXY_TO_PTHREAD moves main() to a worker and transfers the SDL canvas as an OffscreenCanvas. A three-worker pool preserves the existing startup and BGM thread structure.

The relevant linker settings are intentionally explicit:

SettingReason
-pthreadBuild shared Wasm memory and pthread support.
-sPROXY_TO_PTHREADKeep the authored game loop off the browser UI thread.
-sPTHREAD_POOL_SIZE=3Make workers available before authored startup jobs need them.
-sOFFSCREENCANVAS_SUPPORTLet the worker own WebGL rendering.
-sALLOW_BLOCKING_ON_MAIN_THREAD=0Treat accidental main-thread blocking as an error.
-sINITIAL_MEMORY=268435456Provide the fixed 256 MiB shared heap used by the current build.
-sSTACK_SIZE=4194304Reserve the current 4 MiB Wasm stack.
-sGLOBAL_BASE=33554432Keep low raw-address compatibility views available.

Pthreads require SharedArrayBuffer, which browsers expose only to a secure, cross-origin-isolated page. Development and production servers therefore send:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin

Without those headers, the launcher can render but deliberately refuses to start the pthread build.

7. Design the retail-data bridge

The authored archive code expects synchronous Win32 file calls. Browser file selection and Blob reads are asynchronous. Rewriting every archive consumer would spread Web behavior throughout the game, so the compatibility layer keeps the authored-facing API synchronous and adapts only the two known retail basenames.

th08.dat: copy once

The approximately 46.8 MB game archive is read frequently in small pieces. At startup the page reads it into an ArrayBuffer, allocates one region in the Wasm heap through th08_web_allocate_game_data, and copies the bytes through HEAPU8.

After that, CreateFile, ReadFile, seek, size, and close operations can use ordinary C++ memory copies. The bytes are volatile and disappear with the page session.

thbgm.dat: keep the Blob outside Wasm

The music archive is roughly 450 MB. Copying it into the fixed 256 MiB shared heap is impossible and would be wasteful even with a larger heap.

The page keeps the selected browser File. When C++ requests a range, the game worker posts a main-thread operation that performs:

file.slice(offset, offset + length).arrayBuffer()

The resulting bytes are copied into the caller's Wasm destination. A shared atomic status word wakes the waiting worker. A read-ahead cache absorbs nearby BGM reads and reports fetch/cache statistics when the handle closes.

This synchronous facade is safe specifically because it runs on a worker. The same Atomics.wait design on the browser main thread would deadlock the promise needed to complete the read.

Why empty MEMFS entries exist

The launcher creates zero-byte /game/th08.dat and /game/thbgm.dat entries. They satisfy path existence and stat behavior only. The compatibility layer recognizes their basenames and serves bytes from the private memory/Blob bridge; the MEMFS entries never contain retail data.

8. Keep the distribution boundary enforceable

The provenance rule was implemented before public deployment, not left as a documentation promise.

check-web-provenance.py checks tracked source for original executables, DAT files, and common retail containers. When given build/web-dist, it additionally requires exactly these nine regular files:

_headers
_redirects
th08-web.html
th08-web.js
th08-web.wasm
th08-web-firefox.html
th08-web-firefox.js
th08-web-firefox.wasm
th08-web-icon.png

Unknown files, missing files, symbolic links, retail/archive suffixes, and forbidden basenames fail the release. The deployment workflow uploads only this directory, never the repository root or a test data directory.

At runtime there is no upload endpoint. Browser developer-tools inspection shows network requests for static Web files, not the selected DAT objects.

9. Bring up rendering, then replace the bring-up renderer

The reconstructed game speaks through an IDirect3D8/IDirect3DDevice8-shaped compatibility surface. That was the correct seam to preserve: game code could continue issuing its original render states and draw calls while the backend changed underneath it.

Initial bring-up

The first visible build used Emscripten's legacy fixed-function/immediate-mode OpenGL emulation. It was useful because it answered whether textures, matrices, and the authored draw chain could reach a browser framebuffer. It was not a production renderer.

TH08 submits many small sprite and bullet draws. Legacy emulation repeatedly translated fixed-function state and immediate vertices in JavaScript-facing GL machinery. Browser callbacks could remain near 60 Hz while authored calculation frames fell toward 40 Hz, producing delayed input and visibly incomplete danmaku.

Direct WebGL 2 renderer

The hot path was replaced rather than micro-optimized. The Web backend now:

  1. creates a WebGL 2 context directly on the worker-owned canvas;
  2. converts the small D3D flexible-vertex-format family used by TH08 into one explicit WebVertex form;
  3. records compatible draw commands and batches adjacent state;
  4. uploads the frame's vertices through rotating VBOs;
  5. renders with a GLSL ES 3.0 program;
  6. caches blend, depth, sampler, scissor, alpha-test, fog, texture-stage, and texture bindings;
  7. flushes only at real ordering boundaries such as texture mutation, clear, framebuffer copy, or presentation;
  8. uses a separate shader blit for the backbuffer and dialogue snapshots.

The renderer also makes the D3D/OpenGL differences explicit: post-transform depth conversion, color coefficient behavior, framebuffer ownership, texture coordinate transforms, and vertical orientation are handled in known code rather than hidden legacy emulation.

Representative Chromium gameplay measured about 0.08--0.15 ms of CPU draw submission plus 0.01--0.03 ms for the final blit per frame. More importantly, five-second samples observed browser callbacks and authored calculation frames advancing in lockstep.

10. Handle browser presentation differences

Chromium composites the completed worker-owned OffscreenCanvas directly when the main-loop callback returns.

Firefox accepted the same WebGL 2 commands and contained a valid completed framebuffer, but the transferred canvas placeholder did not visibly update in the tested configuration. The first correctness fix called transferToImageBitmap() after the final blit, transferred the bitmap to the main thread, and presented it with bitmaprenderer.

The important diagnostic step was to time each boundary independently instead of attributing the result to generic Wasm or browser overhead. The launcher can enable low-overhead probes with ?perf=1, while exported C++ snapshots count browser callbacks and authored calculation frames. Measurements separated:

  • main-thread requestAnimationFrame spacing;
  • worker-side transferToImageBitmap() duration;
  • worker-to-main message latency and bitmap arrival spacing;
  • the main-thread bitmaprenderer call;
  • authored simulation progress, movement distance, active shots, draw calls, and submitted vertices.

That bridge was functionally correct but not a suitable hot path. Firefox's WebGL snapshot implementation performs a synchronous GPU-to-CPU readback. TH08 diagnostics measured an average 38.82 ms and a maximum 82.32 ms per snapshot in one software-rendered Lunatic Stage 1 run; bitmaprenderer itself averaged only 0.04 ms. The browser main thread and message transport were not the primary bottleneck.

The optimized release uses two links of the same compiled game objects:

  1. Chromium keeps the worker-owned OffscreenCanvas and implicit compositor presentation.
  2. Firefox leaves the visible canvas on the main thread.
  3. The game still runs under PROXY_TO_PTHREAD, preserving startup and BGM worker behavior.
  4. Emscripten OFFSCREEN_FRAMEBUFFER proxies the already batched WebGL command stream and emscripten_webgl_commit_frame() performs the explicit swap.
  5. The launcher selects th08-web-firefox.html before the user chooses DATs.

The link-time split is explicit:

Shared by both links:
  -sPROXY_TO_PTHREAD
  -sOFFSCREENCANVAS_SUPPORT

Firefox link only:
  -sOFFSCREENCANVASES_TO_PTHREAD=""
  -sOFFSCREEN_FRAMEBUFFER
  proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_ALWAYS

The build compiles the authored game and compatibility objects once. It first links th08-web.html/.js/.wasm, then reuses those objects for th08-web-firefox.html/.js/.wasm. The early launcher script redirects a Firefox visit before the user selects retail files or starts the Wasm runtime. Chromium therefore pays no proxy cost, while Firefox never enters the bitmap snapshot path during normal play. Cloudflare Pages canonicalizes HTML files to extensionless paths, so the same predicate recognizes both th08-web-firefox.html and /th08-web-firefox; the provenance/CI gate requires the early router and runtime mode selection to share that predicate.

The proxy Present path deliberately calls glFlush() immediately before emscripten_webgl_commit_frame(). Removing that apparently redundant flush in a back-to-back Stage 1 probe reduced simulation progress and movement distance under the same loaded software-renderer setup. The explicit commit performs the framebuffer blit, while the flush makes the preceding proxied command stream ready promptly; the measured version keeps both boundaries.

Keeping pixel data on the GPU is the decisive property. The old path copied a 640x480 completed framebuffer through CPU-visible bitmap storage every frame. The new path proxies only the renderer's small command stream, draws into Emscripten's offscreen default framebuffer on the main-thread WebGL context, and performs the final copy with a GPU framebuffer blit. This would not have worked well with the initial immediate-mode renderer; the direct renderer's state cache, command batching, and single vertex upload are prerequisites for making the proxy narrow enough.

Neither retail archive was the per-frame bottleneck. th08.dat is copied once into session memory because its small random reads are frequent. The roughly 450 MB thbgm.dat remains a browser File and is range-read, so it is never copied wholesale into the fixed Wasm heap. The measured stall occurred after rendering, at frame presentation, independently of archive access.

Two tempting shortcuts were rejected by screenshots and startup evidence. Direct Firefox worker presentation remained black despite valid framebuffer pixels. Moving the whole Wasm program to the browser main thread blocked its startup event loop. WebGL context proxying also failed until the fixed Emscripten source showed that the proxy implementation is compiled only with PTHREADS && OFFSCREEN_FRAMEBUFFER.

The evidence progressed in layers:

Environment and pathObservation
Firefox bitmap, Xvfb/llvmpipe, Stage 1About 24 FPS; snapshot averaged 38.82 ms and peaked at 82.32 ms.
Firefox proxy, Xvfb/llvmpipe, Stage 1About 33--36 FPS; no bitmap readback; movement, shots, bullets, HUD, audio, and callback/calculation alignment remained active.
Firefox proxy, real hardware, 2026-08-26Operator reported gameplay generally above 50 FPS with substantially better responsiveness.

The first two rows are controlled relative software-renderer evidence. The hardware row is a preliminary user observation rather than a benchmark because the exact browser version, GPU, scene, and trace were not captured. It confirms that the architecture improves real play, but complete-route endurance and a controlled hardware trace remain separate acceptance gates. Chrome remains recommended until that coverage is complete.

11. Make keyboard edges survive scheduling

The initial keyboard path could move the player but still lose short Z or X taps. A DOM key event can begin and end between two authored 60 Hz input polls, especially when rendering or worker scheduling is temporarily late.

The launcher now maps DOM KeyboardEvent.code values to the virtual keys used by the compatibility layer. Two shared atomic arrays record:

  • whether a key is currently held;
  • whether a down edge occurred since the last authored poll.

Each DirectInput-shaped poll consumes the pending edge with an atomic exchange. A quick tap therefore appears for at least one game poll even if the physical key is already up. Losing window focus clears both arrays so movement and fire cannot remain stuck.

This distinction was essential for a bullet-hell game: average throughput was not enough; individual action edges had to be deterministic.

12. Bridge audio and Japanese text at their real boundaries

Audio

The authored DirectSound-shaped interface and mixer remain in C++. SDL owns the Web Audio device, but browser audio lifecycle operations must execute on the main runtime thread. Small synchronous proxies open, pause, and close the device there, while mixing and BGM stream logic remain with the game.

The first call happens after Start TH08, preserving the user gesture needed by browser autoplay policy. BGM samples arrive through the thbgm.dat range bridge rather than a network URL.

Dynamic text

TH08 uses GDI-shaped text drawing for content such as Music Room names and dialogue-related dynamic surfaces. SDL font substitution alone did not cover the browser path reliably.

The Web adapter copies short Shift-JIS strings out of shared Wasm memory, decodes them with TextDecoder('shift_jis'), rasterizes them on a temporary 2D canvas, reads the alpha coverage, and blends that coverage into the authored 16-bit or 32-bit destination bitmap. The worker waits on the same bounded main-thread/atomic pattern used by Blob reads.

The intermediate byte copy matters: browser TextDecoder rejects views backed by SharedArrayBuffer in this context.

13. Adapt ABI assumptions without rewriting behavior

Wasm32 and the original x86 target both use 32-bit pointers, but they are not the same ABI.

Indirect call signatures

WebAssembly validates indirect function-table signatures. Native x86 code can call through some callback shapes while ignoring a nominal return value; Wasm traps if the table signature does not match. Web-only typed adapters normalize Win32 thread entries and affected callback tables while leaving the native and reconstruction branches unchanged.

Overlapping global ownership

The original PE gives several named global views addresses that overlap fields inside larger manager objects. Native fixed-address layouts can preserve this implicitly. Relocatable Wasm globals cannot.

Web-only references therefore bind ECL state, player/gauge fields, effect and GUI tables, enemy timeline storage, and spell callback lifetime to their actual aggregate owners. Runtime diagnostics verify the highest-risk aliases.

This was not cosmetic. Split storage previously left stale spell jobs alive across stage transitions, causing missing effects, unstable score behavior, and an eventual out-of-bounds trap when leaving a result path.

Explicit runtime values

Values that the original process obtained through fixed target-image layout, such as the initial ECL time scale, are initialized explicitly at the Web platform boundary. The authored consumers remain unchanged.

14. Persist saves without persisting retail data

Mounting IDBFS at /game would be convenient but unsafe: it would make the retail archive namespace persistent too. The final layout uses narrow mounts:

/game                         volatile MEMFS
├── th08.dat                  zero-byte name; bytes live in session memory
├── thbgm.dat                 zero-byte name; bytes live in a browser File
├── th08.cfg   -> /save/...   allowlisted persistent link
├── score.dat  -> /save/...   allowlisted persistent link
├── score.txt  -> /save/...   allowlisted persistent link
├── replay/                    dedicated IDBFS mount
├── backup/                    dedicated IDBFS mount
└── snapshot/                  dedicated IDBFS mount

/save                         dedicated IDBFS mount

Separate directory mounts preserve the original chdir("replay") followed by chdir("../") behavior: the game returns to /game, not to an unrelated persistent root.

Before startup, the launcher restores all mounts, removes any retail DAT name found anywhere in them, creates the allowlisted links, and enables IDBFS autoPersist. If IndexedDB is unavailable, the same namespace works as session-only MEMFS and the launcher reports the fallback.

15. Optimize the product build, not just the renderer

The first public script accidentally built Debug, producing a Wasm file around 21 MB. The normal build now configures CMake Release and -O2; the staged JavaScript is about 232 KiB and Wasm about 1.4 MiB.

Other performance decisions are architectural:

  • keep the game loop on a worker so browser UI and file promises progress;
  • yield exactly once per authored outer frame;
  • remove legacy GL emulation from the hot path;
  • batch vertices and cache render state;
  • copy the frequently read small archive once;
  • keep the 450 MB music archive outside the Wasm heap;
  • latch input edges instead of compensating with repeated synthetic keys;
  • give Firefox its own presentation boundary without charging Chromium for it.

The final blit owns its texture binding and sampler state. Removing the redundant bind plus minification and magnification filter assignments from Present saves three GL commands per frame; on the Firefox link those would also have been three main-thread proxy crossings.

A later Chrome/macOS endurance report showed that a short title or Stage 1 sample was not enough: denser gameplay could decay to 30--40 FPS. Inspection found that the dynamic upload ring rotated per upload and repeatedly replaced offset zero with glBufferSubData(), but did not orphan an already-sized VBO. That can serialize the CPU behind an in-flight ANGLE/Apple-GPU buffer even with three object names.

As adjacent-port evidence, some100/th07's reallyportable renderer rotates the buffer at frame start, allocates fresh GL_STREAM_DRAW storage, and appends later uploads. TH08 adopted that storage lifecycle, not the rest of the port wholesale. In particular, TH07 can preload its assets and run SDL3 app callbacks on the browser main loop; TH08 must retain pthread execution and range-read thbgm.dat, so removing PROXY_TO_PTHREAD would break existing synchronous platform boundaries rather than constitute a free optimization.

The asset difference suggested a second bounded optimization. TH08 continues to expose synchronous Win32-shaped reads to authored code, but after each 1 MiB sequential BGM fetch the browser starts the next File.slice() promise without waiting for the cache to empty. The predicted offset advances by the complete authored notification reads that fit in the cache rather than by a physical 1 MiB boundary, preserving the short unused tail behavior. The following miss consumes that prefetched ArrayBuffer when available; seeks and prefetch failures retain the direct-read fallback. Only one future chunk is held, avoiding TH07's whole-file preload cost while removing the normal periodic promise wait from BGM playback. ?perf=1 labels every BGM range as direct or prefetched and records the worker wait, so this hypothesis remains measurable on macOS rather than being hidden inside the FPS counter.

The TH08 Web path now orphans one buffer at the start of each presented frame, streams the batched game geometry and final blit into increasing offsets, and expands the initial 1 MiB store only when required. It also converts vertices directly into the persistent queue and merges only adjacent triangle lists whose captured state is identical. The change deliberately does not copy TH07's render interpolation because TH08 lacks the corresponding previous-state render model.

Repeating diagnostics then observed a proxy case with 60 worker callbacks but only about 50--51 authored calculations per second. The original-shaped Web timestamp gate ran at most one calculation per callback and discarded missed intervals. A Web-only accumulator now keeps 60 Hz authored steps, clamps catch-up to 100 ms after long stalls, and renders once after all required calculations. This preserves replay frame order and leaves native/VC7 behavior untouched. It does not pretend that rendering is 60 Hz: the visible diagnostic continues to report browser, worker, and calculation rates separately.

The pinned build script deliberately favors predictable workstation load over maximum throughput. Compilation is single-job, and each Docker invocation defaults to two CPUs, 4 GiB of memory, and no memory beyond that limit through swap. TH08_WEB_BUILD_CPUS and TH08_WEB_BUILD_MEMORY can override those caps when a builder needs a smaller or larger envelope.

The in-game FPS label alone was not accepted as a performance oracle. Instrumentation separately counts browser callbacks and authored calculation frames, because a browser can call the outer loop at 60 Hz while the game itself advances more slowly. With ?perf=1, the launcher now shows those three rates beside browser rAF in repeating five-second windows, while the runtime log reports repeating 600-frame average/maximum game submission, final-blit, and streaming-upload costs. The default path still stops sampling after its first 600-frame summary.

16. Verify correctness beyond the title screen

Reaching the title proved only archive loading, startup, and a shallow render path. Correctness testing moved outward in layers.

LayerAcceptance signal
StartupBoth local archive boundaries activate; title assets load; no network DAT request exists.
InputShort Z/X taps, held movement, focus, pause, and blur behavior reach authored state.
RenderingTitle, dialogue, Music Room, player shots, focus marker, bombs, enemies, bullets, fog, and spell effects appear.
TimingBrowser callback count and authored calculation count remain aligned in bounded samples.
Route logicExpected stage branch and spell sequence appear through a full Lunatic Final-B route.
LifecycleEnding/result, score write, title reconstruction, and a second start complete without a Wasm trap.
PersistenceConfig and save probes survive reload; recursive inspection finds no DAT in IDBFS.
Save/replay integrityA native 16,048-byte score, bundled demos, and an external retail-format replay load; malformed compressed sizes, truncated replay headers, and invalid decoded offsets are rejected before use.
Stage 5An external Stage 5 replay renders its background, bullets, player, and HUD without a worker or Wasm abort.
Browser boundaryChromium direct presentation has complete-route coverage; Firefox bitmap has historical complete-route coverage, while the faster proxy path has Stage 1 automation and preliminary 50+ FPS hardware play.
DeploymentPublic HTML/Wasm carry isolation headers; Wasm MIME and remote digest match the local release.

Long-route automation used locally selected retail files and isolated, test-only instrumentation. Test hooks were built into separate ignored artifacts and removed immediately after the run; they were never staged or deployed. Runtime snapshots observed stage, spell, score, player, input, frame, and alias state without replacing authored progression.

The verified route details and observed spell sequence are recorded in WEB_ARCHITECTURE.md.

17. Publish as a static, isolated site

The release is intentionally serverless. Once the browser downloads the four runtime assets, the game runs locally and users do not share game state.

Cloudflare Pages was selected because its static _headers file can attach the COOP/COEP/CORP policy required by pthread Wasm. _redirects maps the site root to the generated launcher. Bare GitHub Pages can host the bytes but has no supported repository-defined response-header mechanism for this build.

The production workflow in deploy-web.yml runs only for main and performs:

  1. repository validation;
  2. a clean build in the digest-pinned Emscripten 6.0.8 image;
  3. the exact artifact/provenance check;
  4. Cloudflare Pages upload with cloudflare/wrangler-action@v4 and Wrangler 4.125.0.

The account ID and Pages Edit token live only in GitHub repository secrets. Pull requests do not deploy and do not receive those secrets.

The first Node 24 workflow run independently rebuilt and published the site in about three minutes. Production verification confirmed:

  • HTTPS and a secure browser context;
  • crossOriginIsolated === true;
  • correct isolation headers on redirect, HTML, and Wasm responses;
  • application/wasm content type;
  • for that initial deployment, a downloaded Wasm SHA-256 equal to the tested local Release artifact;
  • no Chromium console errors before local file selection.

18. Rebuild the Web port from a clean checkout

The build requires Docker and Python 3. It does not require an installed Emscripten SDK because the image is pinned by tag and digest.

git clone https://github.com/N0zoM1z0/th08-web.git
cd th08-web
scripts/build-web-game.sh
python3 scripts/check-web-provenance.py --artifact build/web-dist
scripts/serve-web.py --bind 127.0.0.1 --port 8000

Open http://127.0.0.1:8000/, select legal files named exactly th08.dat and thbgm.dat, and choose Start TH08.

Useful verification commands are:

python3 scripts/ci.py
python3 scripts/check-web-provenance.py --artifact build/web-dist
git diff --check

To package the exact allowlisted output for a tagged GitHub Release:

scripts/package-web-release.sh v0.1.0

The deterministic archive contains only the nine deployment files and is accompanied by a SHA-256 manifest. It never contains either retail DAT file.

The image digest fixes the compiler and SDK inputs, but this project does not claim that minified JS/Wasm is bit-for-bit identical across different host environments. Emscripten/Binaryen may assign different short internal symbol names on separate runners. For one fixed build/web-dist, the packaging script does normalize file order, timestamps, ownership, permissions, and gzip metadata so repeated archives are byte-identical.

For an authorized production deployment, configure CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN outside the repository, then upload only the checked artifact directory:

npx wrangler@4.125.0 pages deploy build/web-dist \
  --project-name=th08-web \
  --branch=main

19. Lessons from the port

Several decisions generalized beyond TH08.

  1. Compile every authored unit before building a runtime. It separates source portability from platform integration.
  2. Preserve stable engine seams. A D3D8-shaped backend and synchronous file facade kept browser code out of gameplay.
  3. Use a worker when compatibility code must block. The main thread must remain free for promises, audio, input, and composition.
  4. Treat legacy graphics emulation as a probe. Once feasibility is proven, replace the hot path with explicit WebGL state and batching.
  5. Measure authored progression separately from callbacks. UI refresh rate can conceal a slow simulation.
  6. Make provenance executable. An exact deployment allowlist is stronger than a policy paragraph.
  7. Test full lifecycle transitions. Title-only success cannot expose stale callbacks, conditional spells, result writes, or second-start failures.
  8. Expect browser-specific presentation behavior. Share simulation and rendering, but allow a narrow compositor adapter when evidence demands it.

20. Remaining work

The current Web build is playable and publicly deployed. The next engineering work is bounded rather than architectural:

  • capture a controlled Firefox hardware trace and validate the proxy build across a complete route;
  • expand replay, pause/focus, audio-underrun, and repeated-stage regressions;
  • measure the fixed shared-memory ceiling under repeated long sessions;
  • add optional gamepad mapping and broaden the tested desktop-browser matrix.

The core product boundary remains unchanged: source-built gameplay in Wasm, local legal retail data, browser-owned platform services, and a provenance-gated static release.

Primary platform references