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.datandthbgm.datfrom a legal local TH08 installation, and play Imperishable Night in the browser.
That sentence established five non-negotiable constraints.
| Constraint | Consequence |
|---|---|
| Use the reconstructed source | Gameplay remains in C++; JavaScript is only a browser boundary. |
| Do not distribute retail data | DAT files must come from the local file picker and must never enter the deployment artifact. |
| Preserve the existing engine shape | Win32, DirectInput, DirectSound, and D3D8 calls need compatibility surfaces rather than a rewritten game. |
| Keep bullet-hell timing playable | The browser event loop, renderer, and input path must sustain stable authored frame progression. |
| Publish as a normal static site | The 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.

3. Milestone sequence
The port was developed as a series of independently testable gates.
| Checkpoint | What it proved |
|---|---|
78b3157 | All shared authored translation units can compile as wasm32; focused layout, data, and renderer probes are runnable. |
bb4d83f | The full game links, accepts local DAT files, reaches title and gameplay, produces audio, and receives keyboard input. |
3f38ecb | The temporary legacy GL path is replaced by a direct WebGL 2 renderer and Wasm runtime ownership aliases are restored. |
8fa575f | Browser saves persist without allowing either retail archive into IndexedDB. |
1e5ff3a | Firefox receives an explicit frame-presentation path while Chromium keeps direct canvas composition. |
6e1b202 | The static artifact has an exact allowlist and carries Cloudflare isolation metadata. |
aca272d, 6333447 | A 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-extensionsfor 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.shchecks the layout assumptions that matter to the 32-bit source model;scripts/build-web-data-probe.shproves localFileselection and first/last byte access without uploading a DAT;scripts/build-web-renderer-probe.shexercises the graphics boundary without requiring a complete stage;scripts/build-web-probe.shprovides 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:
| Setting | Reason |
|---|---|
-pthread | Build shared Wasm memory and pthread support. |
-sPROXY_TO_PTHREAD | Keep the authored game loop off the browser UI thread. |
-sPTHREAD_POOL_SIZE=3 | Make workers available before authored startup jobs need them. |
-sOFFSCREENCANVAS_SUPPORT | Let the worker own WebGL rendering. |
-sALLOW_BLOCKING_ON_MAIN_THREAD=0 | Treat accidental main-thread blocking as an error. |
-sINITIAL_MEMORY=268435456 | Provide the fixed 256 MiB shared heap used by the current build. |
-sSTACK_SIZE=4194304 | Reserve the current 4 MiB Wasm stack. |
-sGLOBAL_BASE=33554432 | Keep 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:
- creates a WebGL 2 context directly on the worker-owned canvas;
- converts the small D3D flexible-vertex-format family used by TH08 into one
explicit
WebVertexform; - records compatible draw commands and batches adjacent state;
- uploads the frame's vertices through rotating VBOs;
- renders with a GLSL ES 3.0 program;
- caches blend, depth, sampler, scissor, alpha-test, fog, texture-stage, and texture bindings;
- flushes only at real ordering boundaries such as texture mutation, clear, framebuffer copy, or presentation;
- 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
requestAnimationFramespacing; - worker-side
transferToImageBitmap()duration; - worker-to-main message latency and bitmap arrival spacing;
- the main-thread
bitmaprenderercall; - 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:
- Chromium keeps the worker-owned
OffscreenCanvasand implicit compositor presentation. - Firefox leaves the visible canvas on the main thread.
- The game still runs under
PROXY_TO_PTHREAD, preserving startup and BGM worker behavior. - Emscripten
OFFSCREEN_FRAMEBUFFERproxies the already batched WebGL command stream andemscripten_webgl_commit_frame()performs the explicit swap. - The launcher selects
th08-web-firefox.htmlbefore 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 path | Observation |
|---|---|
Firefox bitmap, Xvfb/llvmpipe, Stage 1 | About 24 FPS; snapshot averaged 38.82 ms and peaked at 82.32 ms. |
Firefox proxy, Xvfb/llvmpipe, Stage 1 | About 33--36 FPS; no bitmap readback; movement, shots, bullets, HUD, audio, and callback/calculation alignment remained active. |
| Firefox proxy, real hardware, 2026-08-26 | Operator 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.
| Layer | Acceptance signal |
|---|---|
| Startup | Both local archive boundaries activate; title assets load; no network DAT request exists. |
| Input | Short Z/X taps, held movement, focus, pause, and blur behavior reach authored state. |
| Rendering | Title, dialogue, Music Room, player shots, focus marker, bombs, enemies, bullets, fog, and spell effects appear. |
| Timing | Browser callback count and authored calculation count remain aligned in bounded samples. |
| Route logic | Expected stage branch and spell sequence appear through a full Lunatic Final-B route. |
| Lifecycle | Ending/result, score write, title reconstruction, and a second start complete without a Wasm trap. |
| Persistence | Config and save probes survive reload; recursive inspection finds no DAT in IDBFS. |
| Save/replay integrity | A 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 5 | An external Stage 5 replay renders its background, bullets, player, and HUD without a worker or Wasm abort. |
| Browser boundary | Chromium 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. |
| Deployment | Public 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:
- repository validation;
- a clean build in the digest-pinned Emscripten 6.0.8 image;
- the exact artifact/provenance check;
- Cloudflare Pages upload with
cloudflare/wrangler-action@v4and 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/wasmcontent 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.
- Compile every authored unit before building a runtime. It separates source portability from platform integration.
- Preserve stable engine seams. A D3D8-shaped backend and synchronous file facade kept browser code out of gameplay.
- Use a worker when compatibility code must block. The main thread must remain free for promises, audio, input, and composition.
- Treat legacy graphics emulation as a probe. Once feasibility is proven, replace the hot path with explicit WebGL state and batching.
- Measure authored progression separately from callbacks. UI refresh rate can conceal a slow simulation.
- Make provenance executable. An exact deployment allowlist is stronger than a policy paragraph.
- Test full lifecycle transitions. Title-only success cannot expose stale callbacks, conditional spells, result writes, or second-start failures.
- 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.