WAT OpenGL command encoder
Origin and purpose
This implements the GL transport proposal from Claude session
107a07a4-b280-4ca1-ba54-fd68ab0e4e9c
(session).
The measurements and SimGolf headless recipe are in
simgolf-demo.md.
Previously every guest GL vertex, attribute, and state call entered JavaScript
to update GLCommandStream.Encoder. Moving that work into WAT removes those
individual host calls while preserving the renderer's command format.
guest gl*/wgl*
-> WAT attributes, client arrays, primitive conversion
-> command records in linear memory
-> synchronous batch submission at capacity or a semantic barrier
-> GLCommandStream.replay
-> gl-compat -> gpu-backend
Transport contract
Internal host opcode 0x10001 means
gpu_gl_call(0x10001, wasmByteOffset, byteLength). It returns the last
command's result. The caller owns the memory until synchronous replay finishes;
worker execution must wait for the broker's response before reusing it.
Each record retains the existing eight-word header:
| Word | Meaning |
|---|---|
| 0 | Record byte length, including header and payload |
| 1 | GL opcode, or 0x10000 for packed geometry |
| 2 | Auxiliary value; primitive mode for packed geometry |
| 3 | Captured stack byte length |
| 4 | Original guest pointer, when present |
| 5 | Pointer payload byte length |
| 6 | Copied payload offset relative to the batch |
| 7 | Pointer-copy or pointer-borrow flags |
Packed vertices contain 14 floats: position (3), color (4), texture unit 0 coordinates (2), normal (3), and texture unit 1 coordinates (2). Quads, strips, fans, polygons, and line strips/loops become independent triangles or lines, preserving the existing provoking vertex for flat color.
Queries, context transitions, presents, and explicit flushes submit all earlier
commands before returning. Texture uploads borrow guest memory only during a
synchronous submission. Copied input arrays are captured when the API is called,
before the guest can overwrite them. GDI32 SwapBuffers and the legacy WGL
spelling must both observe this ordering.
Compatibility and validation
The JavaScript encoder exists only in
test/helpers/gl-reference-encoder.js as the reference for parity tests.
The production replay and GL renderer continue to consume the same records.
Parity must include record bytes, barrier return values, guest output memory,
context changes, and capacity-triggered submissions.
Production has one encoder: WAT. The CLI rejects the removed --gl-encoder
option, WASM exports no fallback selector, and both cooperative and Worker host
imports reject direct guest GL calls. JavaScript retains ABI metadata and batch
replay; the browser and guest workers never import the test reference.
Each instance lazily allocates a 2 MiB command buffer and a growing immediate-vertex buffer. Allocations are checked for contiguous linear-memory backing before use; fragmented backing or allocation failure traps instead of overwriting unrelated memory. Context snapshots and attribute stacks grow dynamically without a fixed context or nesting limit. As with the JavaScript reference, frontend attribute snapshots belong to an instance; moving an existing context between separate WASM instances does not transfer those snapshots.
node tools/gen-gl-encoder-abi.js generates native argument-length and barrier
tables. The existing constant and barrier build gates reject stale output.
D3D transport boundary
Inspection corrected one premise of the archived proposal: the two binary
headers are both 32 bytes but have different fields. D3DIM also rotates three
asynchronous buffers, each held until its sequence completes, while GL borrows
one range only for synchronous replay. They cannot share a mutable ring without
changing that ownership protocol. This port preserves the D3D queue and its
fences. Both renderers continue to share gpu-backend.js; GL state and record
semantics remain separate from D3D.
Headless GL runs establish rendering correctness and count boundary crossings. They do not establish browser frame rate. Use the non-default SimGolf clock recipe in the linked notes to avoid its known seed-dependent terrain-generator overwrite.
Verification
node test/test-opengl-wat-encoder.js compiles the production sources and
compares native batches byte-for-byte with the JavaScript encoder. It covers
all ten primitive modes, flat/smooth colors, incomplete primitives, typed and
indexed arrays, both texture units, context/attribute lifetimes, buffer pressure,
copied and borrowed pointers, query results, invalid inputs, and shared-memory
instance isolation. Boundary tests place vector arguments at the end of memory
to catch unused-component reads.
test/test-opengl-command-stream.js also exercises the worker producer and
broker together: the handoff contains only offset/length, and results and guest
output writes are visible before the producer resumes. SwapBuffers ordering
has a separate regression. test/test-opengl-wat-worker.js uses a real Node
worker thread and delayed broker responses to verify two actual Atomics.wait
barriers, query output visibility, ordering, and safe reuse of the same range.
The browser gameplay test can also run with QUAKE2_WEB_THREADS=1; it asserts
that the requested backend is active and that no JS encoder is loaded.
The initial port's headless comparisons used a pinned WASM artifact and a fixed batch limit, before the production reference switch was removed:
| Workload | Frames | JS host calls/frame | WAT host calls/frame | Evidence |
|---|---|---|---|---|
| Quake II startup, 30,000 batches | 17 | 6,453.9 | 40.9 | Identical PNG bytes, draw counts, and vertex counts |
| Warcraft III animated menu, 40,000 batches | 81 | 4,776.6 | 8.0 | Both render the menu; totals differ slightly (127.1 vs 127.0 draws/frame), so this is a visual smoke test |
| SimGolf course, 301,180 batches | 38 | 2,568.7 | 26.4 | Matching spans, draws, vertices, and final PNG |
These counts include startup work. They establish fewer WASM-to-JavaScript crossings, not an FPS improvement or full gameplay acceptance.
To reproduce those historical A/B results, check out 8fda9913, preload
tools/gl-stats-preload.js in test/run.js, and use
--headless-gl --gl-encoder=js and --gl-encoder=wat with the same pinned
--no-build --wasm=PATH, and compare the whole-run counters. Quake II uses
--app=quake2_demo --args='+set vid_ref gl +map demo1' --max-batches=30000;
Warcraft III uses --app=warcraft3_demo --max-batches=40000.
SimGolf uses --app=simgolf_demo --max-batches=301180 --tick-ms-per-batch=37 --stuck-after=100000000 --control=8179.
Current counter probes read WAT exports instead of the retired JS counters. Page probes report unavailable guest-call counts for real Worker instances; executor draw counters remain available for both backends.
Native-only validation (2026-09-15)
The clean eca7b11a baseline and a separate checkout containing only this
retirement patch both passed the full build, including canonical/compat WASM
compilation and data-segment overlap checks. Native parity, real Node Worker
transport, SwapBuffers ordering, client arrays, multitexture, fixed-function
rendering, and D3D async-protocol regressions passed.
Browser validation found and fixed a real compatibility issue: a page may have
shared WASM memory while its SharedArrayBuffer constructor is hidden.
memoryBatch now validates the buffer's internal slot through DataView,
which also accepts buffers from another realm and rejects fake buffer-shaped
objects. The native-only cooperative browser test passed without isolation
headers: 640x480 textured Quake II gameplay, 6,372 colors, and 127,767 changed
pixels after normal keyboard movement.
The real browser Worker run also passed, with 6,340 colors and 220,901 changed
pixels. Both runs asserted the execution backend and absence of a production
JS encoder.
Four alternating headful Chrome samples on the pinned pre-retirement baseline
used the same demo1 scene, 10,000-block slices, five-second warmup, and
20-second CPU-profile windows. The harness did not capture pixels during
sampling; normal renderer readbacks remained enabled.
| Encoder | GL presentations/s | Mean interval (ms) | Host load before/after |
|---|---|---|---|
| JS | 18.91 | 52.87 | 16.8 / 16.3 |
| WAT | 16.74 | 59.74 | 16.4 / 15.2 |
| WAT | 23.22 | 43.06 | 18.2 / 19.2 |
| JS | 20.48 | 48.83 | 17.3 / 17.2 |
There is no established frame-rate improvement: WAT samples fall on either
side of the JS samples, and machine load exceeded the project's
validity threshold of four throughout. JS encoder self samples were about
3.0 ms per GL presentation; samples attributed to native $gl_* functions
were about 1.0 ms. These are sampled self times, not inclusive CPU costs;
shared helpers are charged elsewhere and the dynamic scenes are not identical.
Profiles and screenshots are in /private/tmp/gl-browser-bench/; the harness
is /private/tmp/gl-browser-bench.js and the pinned checkout is
/private/tmp/wa-gl-validation.
The real-phone probe received no device response, so iPhone gameplay remains unverified. Desktop browser results are not a substitute for that check.