Need for Speed Windows demos

Fixtures and launch

nfs2_demo and nfs3_demo are local candidate apps. Fetch with:

node tools/fetch-candidate-corpus.js --id=need-for-speed-2-demo,need-for-speed-3-demo

Sources and archive SHA-1 values are recorded in test/binaries/SOURCES.md and the candidate manifest. Both entries mount the complete extracted tree through .wine-assembly-browser.json. No guest executable patches.

NFS II is the 1997 original software/DirectDraw demo (nfsw.exe), not the Glide-only Special Edition. CLI and cooperative browser runs render its 640x480 main menu. A browser probe also entered a race and applied Up-arrow input: subsequent frames show the race clock advancing, cockpit view, and changed car position. This verifies race entry and input, not a full race.

The separately acquired public NFS II SE 3Dfx demo contains NFS2SEA.EXE (SHA-256 7e357542a3f7a3a67f6fac06c299e6bdc23529f0b9a16d9e7639079e56c039d9). Use tools/nfs2-renderer-bench.js --help for its source/preparation command. It includes TR04 assets rather than the original demo's TR03/Pacific Spirit, so these demos do not provide an isolated software-versus-Glide comparison.

Set the supported guest environment variable THRASH_DRIVER=1 to select Glide. The selector at 0x4b5b50 parses the value as a decimal driver index; 1 is Glide, 2 is PowerVR. Automatic probing currently picks PowerVR because the missing sgl.dll receives the loader's synthetic success handle; its subsequent missing exports cause startup to exit. Explicit selection avoids that unrelated route without modifying the executable.

All 50 decorated Glide exports resolve after adding grTexCombineFunction and guFogGenerateExp. Driver index 0x4d4fc8 is 1, init pointer 0x5553f8 is 0x48dd34, and 0x4d4978 marks completed video initialization. The public demo reaches the tropical coastal race directly, without the original demo's menu walk. Benchmark screenshots and timings are in the renderer benchmark report.

SE W-buffer geometry corruption (2026-09-29)

Driving the Glide demo exposed blue holes through road/beach geometry and missing pieces of cars. NFS II SE uses W buffering and table fog (state[11] = 2, state[22] = 2); GrVertex.oow is valid, while unused ooz contains arbitrary bytes, including NaNs. Our WebGL vertex conversion incorrectly fed ooz into clip-space Z before the fragment shader calculated W depth.

A captured bad frame has 9,708 vertices, 4,765 non-finite ooz values and zero non-finite oow values. The frame begins with a full color/depth clear. Replaying identical resources and 1,427 commands with the old and corrected backends changes 118,588 pixels and restores road, terrain and car geometry. The first captured frame happened to look normal and replayed identically despite some invalid unused Z values; it was not sufficient evidence alone.

W-buffer clip coordinates now use neutral finite Z. Explicit Z fog/combiner inputs retain their separate varying; the software adapter also avoids consuming unused Z. Regression tests cover finite, NaN, infinite and huge unused Z values on WebGL1/2, plus preservation of explicit Z fog. WebGL, native backend and WAT software tests pass. A further acceleration run reaches about 90 on the HUD and the previously affected beach section without the holes. Artifacts: build/nfs2-glide-driving{,-fixed}/, build/nfs2-badframe/, build/nfs2-bad-replay-{before,after}/.

The SE harness supports --capture-frame, bounded --capture-min-blue=N selection for this symptom, and --glide-source=PATH for diagnostic replay of an older backend without replacing production files. Captures include texture RAM, palettes, fog table, frame commands and vertex ranges. The existing NFS II performance measurements predate this correctness fix and must not be treated as timings of fully rendered geometry.

node tools/profile-web-frames.js --app=nfs2_demo --warmup=2 --seconds=25 '--guest-script=click:130:310@35:0.3,key:13@5:0.3,key:38@15:3' --film=/tmp/nfs2-race:10

The click targets the main menu's RACE option. If startup is slower, wait for that menu before clicking; input during the opening title screen is too early.

node test/run.js --app=nfs2_demo --no-build --quiet-api --quiet-blocks --max-batches=10000 --batch-size=100000 --max-seconds=40 --png=/tmp/nfs2.png

NFS III is the September 1998 final demo (nfs3demo.exe), with Corvette, Rocky Pass, and random race/hot-pursuit/weather selection. It needs install.win; just extracting the archive gives a misleading corrupted-files error. The recipe reproduces the 578-byte file from the original installer: English/local mode, relative asset paths, CRLF, final Ctrl-Z. Re-run --prepare --id=need-for-speed-3-demo to update an existing fixture.

The original installer sequence is root setup.exe (PE bootstrap), Setup/English/Setup.exe (NE InstallShield), then captured C:\windows\temp\_ins0432._mp with _wutl95.dll seeded and arguments -fC:\setup\english\SETUP.INS -z1 -cx -xC:\WINDOWS\TEMP\. Welcome, destination, program folder, and shortcut dialogs lead to the installed tree under C:\Program Files\Electronic Arts\Need for Speed III Demo.

NFS III loads its original software renderer softtria.dll at 0x00b30000. With worker threads and real-time clocks, the CLI renders the car and track. The browser also reaches race startup: the Corvette loading screen at 10s, the starting-grid camera at 21s, and cockpit/race HUD at 31s after launch. These are sampled observations, not minimum loading times. Safari itself has not been verified. The earlier 100-second CLI loading stall used the default batch-driven clock and was not a reliable browser reproduction. Raise the CLI stuck threshold: a startup polling loop at 0x004e5ad0 otherwise triggers the default same-EIP detector after only 11 batches.

node test/run.js --app=nfs3_demo --threads --real-ticks --no-build --quiet-api --quiet-blocks --stuck-after=100000 --max-batches=100000 --max-seconds=40 --batch-size=10000 --png=/tmp/nfs3.png

Texture cache page tracking

The WebGL cache now watches only texture/palette/render-target backing pages. Guest aliases and Worker instances share atomic generations; CPU, bulk, native drawing and host writes notify them. An unchanged texture checks page versions instead of scanning pixel bytes. test-page-watch.js covers missed notifications, independent consumers, native rendering, buffer swaps and fallback behavior. Real NFS III gameplay passed a pixel-shadow audit with zero misses through 554k triangles; normal mode made zero texture byte comparisons over 357 measured flips. See dirty-tracking measurements for artifacts and the machine-load limits on the observed FPS.

Compatibility fixes

The browser worker loop must finish pending thread instantiation before starting its next main-thread slice and publishing that slice's clock. Previously main resumed concurrently with asynchronous worker initialization. NFS II could start its timer-readiness deadline before the timer worker existed, then abort with getcpuspeed - INITTIMER REQUIRED TO DETERMINE CLOCK RATE. The check at 0x00483232 waits for the counter at 0x0051e11c to advance, then tears the timer down at 0x0048325b if the deadline expires. The startup barrier keeps actual execution concurrent once workers exist; neither demo disables threads. A deferred-start regression in test-host-raf-present.js fails without the barrier and passes with it. Browser worker runs then reach NFS II's main menu (first sampled at 10s in one run). test-worker-thread-scheduler.js passes all 50 checks, and the browser DirectDraw presentation/vblank tests pass.

Use --real-ticks for these timing investigations: the CLI's default 200ms per batch can expire the timer initialization check before a worker runs.

NFS II needs WaitForMultipleObjectsEx. It now delegates the non-alertable wait to the existing multiple-object handler while preserving its 24-byte stdcall cleanup and APC/wait-resume behavior. Covered in test-read-file-ex.js. Its worker wrapper at 0x004856e0 calls the thread function then returns through the thread sentinel; EIP-zero termination there is not a main-thread crash.

NFS III queries actual VERSION string data. VerQueryValueA/W now traverses the resource tree instead of returning the old fixed product-name string for every key. Tests cover language tables, translations, root pointers, missing keys, malformed children, case-insensitive keys, wide strings, and unchanged input bytes. The Win16 synthesized GDI version translation trailer is handled in its wrapper, preserving test-win16-version.js behavior.

Validation: full build, test-file-version-info.js, test-read-file-ex.js, test-static-dx-version.js, and test-win16-version.js pass. The broad app registry check has unrelated missing Baldur's Gate/Snood fixtures in this shared checkout.

NFS III D3D versus Glide profiling (2026-09-29)

The isolated Glide branch benchmark predates main's ff6dc0f4 texture generation tracking. Its D3DIM _texture checks unchanged bytes on every textured draw; a worker CPU profile confirms bytesEqual is hot. Main already eliminates those scans, so the old 18.45 versus 7.53 FPS comparison must not be presented as a comparison against current main D3D.

Updated-base rerun: merge ed50bc08 includes main 3344817a and the texture tracking fix. Two 30-second headful samples per route measured Glide 13.98, D3D 10.53, original software 3.18 FPS at 640×480 (high load 22–35, provisional). D3D textureByteChecks=0 over 633 frames; syncs=2/frame, syncMs=7.91/frame, fallbacks=24.55/frame. Artifacts: build/nfs3-benchmark-updated/. The earlier 2.45× ratio is superseded by the updated-base observed 1.33× comparison. Fences supply current GPU pixels to CPU primitive fallbacks, DirectDraw pixel access and DIB-based Flip presentation; pending/dirty flags coalesce repeated CPU operations until another GPU draw. Aggregate counters do not identify the specific two triggering calls per frame. See the benchmark report for details.

Follow-up caller census identifies both triggers in all 334 measured frames: d3da.dll RVA 0x4ce9 invokes Device2 DrawPrimitive with LINESTRIP=3, TLVERTEX=3, count=2, flags=12; first fallback fences pending GPU work. RVA 0x4fd4 invokes Surface Flip(NULL, DDFLIP_WAIT=1), causing the second fence. No Lock-triggered readbacks occur during these samples. Guest stack arguments and captured WASM call stacks independently confirm both. Artifacts: build/nfs3-readback-callers/; reproduce with --readback-census in the NFS III harness. Prior uninstrumented synchronization time accounts for about one third of the measured wall-time gap, not all of it.

GPU LINESTRIP support added afterward: adjacent segments now use the existing GPU line-list path, with each segment's first-vertex flat color. The NFS III validation run (build/nfs3-linestrip-gpu/) measured 433 frames with zero fallbacks, about 24 GPU lines/frame, one readback/frame exclusively from Flip, and zero framebuffer re-uploads. The line fallback's readback is eliminated. Rain remains visible and renderer error counts remain zero. This leaves the DIB-based presentation readback as the remaining transfer in this workload.

Post-fix CPU profiles (build/nfs3-post-lines-profile/, 4f5e6330) show the D3D guest-main worker spends about 68% of sampled elapsed time in WASM (mostly x86/x87 execution), 20% in draw processing and 6% synchronizing. D3D still submits about 346 GPU draws/frame versus Glide's 221; its draw timer is 21.45 ms/frame, Flip sync 4.66 ms/frame. Fixed-function shader source generation/metadata lowering repeats before the GPU program cache lookup and accounts for roughly 3.5 ms per measured frame. Static-plan caching and ordered draw coalescing were the next candidates (implemented in the follow-up below). Machine load 45–67 and profiling overhead preclude stable FPS claims.

The follow-up caches at most 64 validated fixed-function TL shader plans per D3D9 device, refreshing viewport/depth/fog/alpha uniforms per submission. Other fixed or mixed shader pipelines retain the full compiler. D3DIM also coalesces adjacent draws with identical complete lowered state and immutable texture generation, preserving primitive order and owning the vertex bytes. Batches are bounded to 64 KiB and flush before texture changes, clears, target/backing changes, CPU uploads, fallback and fences. New textures and unvalidated states submit immediately; deferred failures are surfaced rather than falling back only the last accepted draw. drawCalls counts guest draws, draws counts actual GPU submissions, mergedDraws counts eliminated submissions, and submitMs isolates backend submission time. drawMs now includes descriptor validation and deferred flush work as well.

For CPU emulation, the same profiles suggest measuring guest-PC hot regions and micro-op decline reasons before extending the tier across short leaf calls or mixed integer/x87 loops. The Glide worker's x87 island fast path is 9.49% of sampled elapsed time; halving that alone saves only about 4.7% of worker time. Load/store dispatch and block transitions are additional candidates. Do not equate broker/Atomics waits with x86 compute or assume that moving all x87 stack slots into locals wins: docs/x87-realistic-region-bench.md already records the difference between per-op dispatch and profitable longer regions.

Follow-up guest-PC/census profiling is complete in nfs3-emulation-profile.md: three windows each on Glide/D3D consistently identify the integer loop at 0x4c5f28..0x4c5f41 (11–14% of residual block entries), interrupted by unsupported MOVSD pairs. General MOVSD micro-op lowering was subsequently implemented and measured on box3: the isolated record loop uses 47–56% less CPU, and the targeted residual block entries fall by over 99.97%. Whole-game FPS is unchanged within noise on the four-vCPU SwiftShader box; this is not a demonstrated hardware-GPU gameplay speedup. The full compiler regression suite passes, including direction, overlap, sparse-page seams and code invalidation. See the linked report for the remote A/B and profile limitations. The repeated four-FST store loop at 0x4dec44 contributes another 2.5–2.9%; memory guard failures were zero. These are entry shares, not predicted time savings.

The optional tools/nfs-renderer-bench.js --profile census identified every sampled software fallback as primitive 3 (line strip), vertex type 3 (TL), count 2: 2,496 calls over 104 frames. Rain is the likely source. Two full-DIB readbacks per frame remain, not one per line. Earlier counters show virtually identical triangle totals for D3D/Glide but 386 versus 229 GPU draws/frame and 40% more guest blocks for D3D. See docs/nfs-renderer-benchmark.md for evidence, profiling limitations, artifacts and reproduction.

NFS II game step (GAME/s)

nfsw.exe has no frame pointers, so --trace-stack EBP walks are garbage; the chain below came from --trace-stack=IDirectDrawSurface_Lock:12 --trace-stack-scan during a race, confirmed with --count.

The race runs on worker thread T2. 0x4311f9 calls 0x443bb1 in a loop; 0x443bb1 runs one race session (1 hit per race) and calls the per-frame render step 0x43f116 from its loop at 0x444052 (the only call site). 0x43f116 calls 0x43efab with EAX=1,2,3 and presents once through 0x43f0af (474 steps vs 473 presents in one counted race). The 237K-Lock stack through 0x4825fa/0x48eb14 is the multimedia-timer thread, not frames.

perf.logicalFrame is { address: 0x43f116, verifier: 0x444057 }. Because the step runs on T2, the HUD sums get_logical_frame_count across thread instances (ThreadManager.readWasmExportAll); before that it read 0. Browser race, Worker threads: GAME 10.7/s with the verifier agreeing.

CLI route to the race: --threads --real-ticks, then mousedown/mouseup on RACE (130,310) a few times (batch rate varies 50-155/s, and an early click opens Game Setup instead). Cooperative mode runs NFS II at ~1 batch/s.