DX5 SDK boids.exe — 33,423 draws, one-colour frame

test/binaries/dx-sdk/bin/boids.exe, image base 0x400000. A DirectDraw + Direct3D Immediate Mode v2 sample: flocking birds over a wireframe ground grid.

RESOLVED 2026-09-21 — it renders. 1 colour → 223, wireframe terrain in perspective with the flock above it. The bug was ours and it was in the x87: FSIN/FCOS/FSINCOS/FPTAN never wrote C2. Jump to the root cause; everything above it is the (correct) investigation that led there, kept because each step rules something out.

A separate 2026-09-22 blank-frame regression was fixed in the legacy viewport Clear wrappers; see the HRESULT follow-up.

Status was WARN — BLANK (1 colour, 100%). This note records why, because the symptom reads convincingly like a dead rasterizer and is not one.

The frame is blank because the guest's own projection matrix is ±infinity

The app builds its projection at 0x00420720 and hands it to IDirect3DDevice2::SetTransform(D3DTRANSFORMSTATE_PROJECTION). Dumped at batch 70:

node test/run.js --exe=test/binaries/dx-sdk/bin/boids.exe \
  --max-batches=80 --batch-size=100000 --max-seconds=60 --no-close \
  --quiet-api --quiet-blocks --input=70:dump-mem:0x00420720:64
0x00420720  00 00 80 ff  00 00 00 00  00 00 00 00  00 00 00 00
0x00420730  00 00 00 00  00 00 80 ff  00 00 00 00  00 00 00 00
0x00420740  00 00 00 00  00 00 00 00  00 00 80 ff  00 00 80 ff
0x00420750  00 00 00 00  00 00 00 00  00 00 80 7f  00 00 00 00

0xff800000 is −∞ and 0x7f800000 is +∞, so _11, _22, _33, _34 and _43 are all infinite. Every vertex therefore projects to infinity and nothing can land inside the viewport. The matrix is written by guest code, so this is an input we feed it, not a bug in our transform or our rasterizer.

Shape of the SDK's D3DUtil_SetProjectionMatrix: _11/_22 are cos(fov/2)/sin(fov/2) (×aspect for _11) and _33 = far/(far−near). All of them infinite at once means sin(fov/2) came back 0 and far−near came back 0 — one shared upstream zero, not three independent ones.

Everything else in the pipeline is healthy — don't re-check it

Measured over a 300-batch run (--max-batches=300 --batch-size=100000):

call count
IDirect3DDevice2_DrawIndexedPrimitive 33,423
IDirect3DDevice2_SetTransform 21,946
IDirect3DDevice2_SetRenderState 13,956
IDirect3DDevice2_SetLightState 7,977
IDirect3DMaterial3_SetMaterial 6,492
IDirect3DViewport3_Clear / BeginScene 499
IDirectDrawSurface_Flip 498

Lead: 42,117 x87 invalid-operation raises

node test/run.js --exe=... --trace-fpu   # 42,117 lines
[fpu] raise IE at 0x0041623c   (every one of them)

0x0041623c is fld m80real [0x41e620] + fistp inside the CRT helper at 0x00416230, called from 0x0041699a and 0x004169b9. That helper raises FP exceptions on purpose — it is how MSVC's _control87/_statusfp family reports status — so the raises are not themselves the bug. What is suspicious is the volume: 42k deliberate raises means the app's math library is taking an error path tens of thousands of times, which is consistent with the shared upstream zero above.

--trace-fpu shows no ZE at all, so the infinities are not coming from a plain divide-by-zero in our FPU. They come from the CRT taking an error path.

Root cause: FSIN/FCOS never cleared C2

The builder is 0x00407c1b. It is not the SDK's D3DUtil_SetProjectionMatrix: it stores cos(fov/2) straight into _11.

00407c24  fld dword [ebp+0x14]        ; fov
00407c27  fmul qword [0x41c020]       ; * 0.5
00407c33  call 0x414eca               ; cos  -> [ebp-0xc]  -> _11
00407c4d  call 0x414ec0               ; sin  -> [ebp-0x8]

0x414ec0 is mov edx,0x41e592 ; jmp 0x416125, and 0x41e592 is a descriptor beginning with the Pascal string "\x03sin"; 0x414eca is the same with "\x03cos" at 0x41e5b2. Both funnel into the classifier at 0x00418a70:

00418a9d  fxam
00418aa6  fnstsw qword [ebp-0xa0]     ; DD /7, m2byte
00418ab4  mov cl, [ebp-0x9f]          ; status high byte
00418aba  shl cl,1 / sar cl,1 / rol cl,1
00418ac2  and al, 0xf                 ; index = C3 | C0<<1 | C1<<2 | C2<<3
00418ac4  xlat                        ; class table at 0x41f10d
00418ad5  jmp [ebx]                   ; descriptor+0x10 + class

Class table 08 04 08 08 08 04 08 08 00 04 0c 08 00 04 0c 08. A positive normal indexes 8 → 0x00 → the first handler, 0x00415f1a:

00415f1a  fsin
00415f1d  fnstsw ax
00415f20  sahf
00415f21  jp 0x415f2f                 ; taken when C2 is set
00415f23  ret

SAHF takes PF from AH bit 2, which is C2. Real FSIN clears C2 when |ST(0)| < 2^63 and sets it otherwise; that is how the CRT asks "did you need argument reduction?". Our FSIN, FCOS, FSINCOS and FPTAN never wrote C2 at all — and C2 is sticky, so it still held the 1 that the FXAM two instructions earlier had set for a normal number. Every in-range argument took the reduction path and came back infinite.

Fixed in src/06-fpu.wat with $fpu_trig_c2: clear C2 and compute when |ST(0)| < 2^63, otherwise set C2 and leave the stack untouched (NaN counts as in range). FPREM/FPREM1 already did this; the trig ops were the gap. Regression cases in test/test-x86-ops.js run FXAM first on purpose, because that is what makes C2 dirty — without it the bug is invisible.

dx_flip3dtl had the same root cause and now draws its textured rotating Windows 95 cube (77 colours).

This is not a boids-specific bug: it is every guest that reaches x87 trig through an MSVC or Borland CRT, which is the usual way.

Not the same bug as its neighbours

dx_flip3dtl did share it and is fixed too (see above). dx_globe and dx_viewer are a different, known failure — D3DRMERR_BADFILE on sphere3.x/camera.x, the .x loader asset gap.

The 2026-09-19 D3DIM/GL sweep scored dx_boids IDENTICAL, 0% diff between the GPU and software backends. That is two blank frames agreeing, not coverage; see docs/d3d-backend-coverage.md.

Blank again: legacy Viewport Clear omitted HRESULT (2026-09-22)

A later blank frame was not the x87 issue above. The projection at 0x420720 is finite (diagonal words 3f6c835e, 3f6c835e, 3ec46ccc), and textures contain 92 sampled colours. Nevertheless all three large render surfaces hold only the clear colour. The direct primitive renderer is never called: a one-batch, 100000-block trace reports 1619 Viewport2 Clear calls and 1618 flips, with no draw calls.

Original Microsoft DX5 samples/boids/boids.cpp, D3DScene::Render, tests Clear's HRESULT against D3D_OK and returns before BeginScene if it is nonzero. Our Viewport1 and Viewport2 Clear wrappers called the clearing helper but never wrote EAX. They therefore returned whatever register value the guest had left there. Viewport3's wrapper already writes S_OK correctly.

The two legacy wrappers now forward to the shared Viewport3 handler, retaining its worker fence, return value and identical four-argument stdcall cleanup. This is not a new always-success shortcut: it uses the existing implementation and leaves its rectangle/error-policy limitations unchanged. The interface regression seeds EAX with 0xdeadbeef, invokes Clear through each version, and checks S_OK, ESP+20 and the following stack guard. Before the fix it fails with 3735928559 !== 0; after the fix all three versions pass.

The existing real-app line test now honours WINE_ASSEMBLY_WASM for isolated candidate testing (a missing pin is an error) and uses quiet API logging. Its 9000-batch run, 8000-batch capture and image assertions are unchanged. The original noisy run timed out at its 180-second harness limit on this loaded host; independent short probes established the blank frame instead of interpreting that timeout as rendering evidence.

With the isolated rebuilt candidate:

Artifacts/logs: /private/tmp/wa-boids-clear-fixed.wasm, /private/tmp/wa-boids-clear-fixed.png, /private/tmp/wa-boids-clear-fixed.log. The failing projection/census probe is /private/tmp/wa-boids-isolated.log; the clear/flip trace is /private/tmp/wa-boids-draw-trace.log. No performance claim is made, and this does not certify native near-plane line clipping.

The full main build also passes (1,505,311-byte normal / 1,507,717-byte compat; layout ef4939693f389572). The main normal artifact is byte-identical to the isolated candidate, SHA-256 66b533e57bc12b889be5d8c465dce44bf2ece4236f327f25f813aaa32db64ef4. The browser probe tools/web-input-probe.js --app=dx_boids with a six-second post-launch wait shows multiple coloured birds over the terrain grid; capture /private/tmp/wa-boids-browser-fixed.png was visually inspected.

Repeating the original unpinned-clock test against those identical artifacts gave both a two-colour failure and a nine-colour pass. SDK boids.cpp:281 calls srand(time(NULL)), so the flock's position at the capture depends on launch calendar time. The test now pins --wall-clock-ms=978307200000 using the existing harness option. It does not loosen its colour/geometry assertions or change the runtime clock default for users. Two consecutive pinned-clock runs pass identically: 14 colours, 0.88% geometry.

Neighboring HRESULT audit (2026-09-23)

After the Clear fix, an omission scan of all 177 handlers in 09aa-handlers-d3dim.wat found no further handler without a reachable EAX store or explicit trap. The temporary scan followed direct helper calls across the source files. This is only an existence check: a store on one branch does not establish that every returning branch initializes EAX. It is not a conformance gate or evidence that the remaining silent-success APIs are correct.

The durable coverage is in test/test-d3dim-viewport-query-interface.js: 29 public-dispatch calls start with poisoned EAX and check HRESULT, exact stdcall cleanup and a stack guard. These cover Clear, Set/GetBackground with no material, and Set/GetViewport on versions 1/2/3, plus Set/GetViewport2 on versions 2/3. Rectangle round-trips run with both direct allocations and a structure crossing nonadjacent sparse backing pages; an output guard catches overwrites. All pass against freshly compiled current sources, alongside the existing GUID, identity, sparse-output and reference-lifetime checks.

No additional runtime fix was warranted by this audit. Full viewport field retention, null/size validation, nonzero background material semantics and all-path return analysis remain outside this test. In particular, the current shared viewport helper only retains the rectangle; passing this regression does not certify its scale, clipping-volume or depth fields.

Legacy viewport descriptor validation (2026-09-23)

The original Microsoft DX5 d3dimref.doc (archival source and hashes in docs/d3dim-execute-data-sparse-review.md) defines both D3DVIEWPORT and D3DVIEWPORT2 as eleven DWORD/float fields, 44 bytes, and requires an initialized dwSize. Its Get/SetViewport and Get/SetViewport2 entries list DDERR_INVALIDPARAMS among the errors. The converted reference locations are 1370–1403, 1476–1509 and 3392–3481 in the recovered d3dimref.txt.

Previously the shared setter ignored size and accepted null; the getter also accepted null and replaced a zero size with an invented 80. Both now validate the nonnull descriptor's size through the same guest-scalar helper and return DDERR_INVALIDPARAMS (0x80070057) before mutation if it is not 44. The regression first failed with S_OK instead of that error. It tests sizes 0, 20, 40, 43, 45, 80 and 0xffffffff, plus null, through all five applicable interface/method pairs. Direct buffers and two sparse layouts exercise a crossing rectangle field and a crossing size DWORD. Rejected getters leave the entire descriptor and guard untouched; a subsequent valid getter proves rejected setters did not replace the rectangle. Every call poisons EAX and checks stack cleanup.

Two renderer fixtures had copied the same erroneous size80 convention: test-d3dim-indexed-texture.js and test-d3dim-v3-vertex-buffer-draw.js now declare size44. Their rendering expectations were not changed; both pass, including the vertex-buffer test's 961 indexed pixels.

This fixes input validation, not full viewport state. Retaining scale/clip/ depth fields, applying them to transformation, cross-layout conversion and invalid-object handling still need work. Exact native error precedence is not established by the SDK's error list and has not been claimed here.

Validation: all 399 poisoned-EAX/ABI calls pass; indexed-texture and v3 vertex-buffer tests pass; full build passes (1,505,321-byte normal / 1,507,727-byte compat, unchanged layout ef4939693f389572). Boids against that rebuilt artifact still passes at 14 colours and 0.88% geometry with the pinned clock. Fragment/logical-operand/ESP/epilogue/interface/tier checks and duplicate ratchet pass; quiet inventory remains 243+22, duplicates 117/471. Build log: /private/tmp/wa-viewport-validation-build.log. No new browser, native-runtime or performance comparison was performed for this change.

Follow-up: Microsoft runtime viewport contract establishes that full state must be canonical Viewport2 with legacy conversion, not independent byte-for-byte retention of both layouts. It also identifies the initialized flag and attached-device checks still missing here. The size44 validation is corroborated by the original runtime's explicit checks.