D3D backend coverage: what actually draws on each backend, 2026-09-20

First pass at the D3D backlog item "gameplay captured on both rendering backends". Its job is coverage, not agreement: which apps put a 3D frame on the screen when the GPU path draws, which do it on the software path, and which do neither — with the picture as the oracle, not a draw counter.

All headless CLI, no browser. Captures in build/d3d-backend-coverage/ (gitignored), named <app>-<mode>-canvas.png (the composited screen, what a user sees) and <app>-<mode>-surface.png (the DirectX surface --png prefers).

The two backends, and what "gpu" and "software" mean here

Two unrelated control surfaces, unified in index.html's D3D_MODES into one dropdown (webgl, webgl-all, software):

family browser knob CLI, gpu arm CLI, software arm
DX2-7 (D3DIM), lib/d3dim-gpu.js window.WINE_D3DIM_GPU --headless-gl --d3dim-gpu (no flags)
D3D8/9, lib/d3d9-host.js window.WineD3D.renderer --headless-gl --d3d9-renderer=software

--d3d9-renderer=webgl is rejected by test/run.js; --headless-gl is how the CLI reaches the WebGL D3D9 backend.

--headless-gl needs a live display. caffeinate -u alone is not enough — two runs in this sweep died with No suitable display found for a new GLFW Window*** CRASH: WebGL is unavailable from D3DIMGpu._target. Hold the display with nohup caffeinate -d -u -t 2400 & and re-run; both then passed. A GPU-arm crash inside d3dim-gpu.js is this environment failure until the [gl] ... GLFW sees ZERO displays line is ruled out.

Results

gameplay = a live 3D scene reached through an input route. menu = a real screen but not gameplay. blank = the window draws no geometry at all.

app family gpu software note
mw3 (MechWarrior 3 demo) D3DIM gameplay gameplay both reach the cockpit; terrain hue differs (see below)
gta2_demo D3DIM gameplay gameplay Wild Demo playfield on both; GPU arm is visibly smoother
mcm (Motocross Madness trial) D3DIM gameplay gameplay live race on both; GPU arm filters the terrain texture
pawn (Pawn 3 chess) D3D9 gameplay CRASH see the Pawn row below
dx_boids D3DIM blank blank 84,869 GPU draws / 5.7M triangles and the frame is one colour
dx_flip3dtl D3DIM blank blank 115,686 GPU draws; frame is black + a yellow text overlay
dx_twist D3DIM blank blank draws=0 both arms; black + fps text
dx_tunnel D3DIM blank blank draws=0 both arms; black + fps text
dx_globe D3DIM blocked blocked modal Failed to load sphere3.x / D3DRMERR_BADFILE, then STUCK
dx_viewer D3DIM blocked blocked modal Failed to load camera.x / D3DRMERR_BADFILE, then STUCK

Differences between the backends, measured

Pixel-diff percentages at tolerance 0 are meaningless on a textured 3D scene — every 565 rounding step counts. Each row below is quoted at --tolerance=32 beside that app's own null band (the same arm run twice), which is the only scale that makes the number readable.

app backend diff (tol=32) null band (tol=32) what changed
mw3 4.07% 0.026% terrain is green on software, brown/tan on GPU; sky and HUD agree
gta2_demo 1.59% 0.000% (bit-identical rerun) glyph and sprite edges; GPU arm smoother
mcm 1.39% not measured (see below) terrain texture filtering: blocky/dithered on software, smooth on GPU

For MCM the null band was not run (the route is ~185,000 batches), but the two arms' batch-60000 loading-screen captures are bit-identical (0 of 307200 pixels differ), which is strong evidence the route itself is deterministic and the 1.39% at batch 185000 is the backends disagreeing.

MW3's is the one that looks like a bug rather than a filtering choice: the same terrain texture comes out a different hue, not a different sharpness.

Pawn: the D3D9 software backend crashes

Pawn plays on the GPU arm — the e-pawn drag lands, the engine replies, and the yellow last-move outline is in the capture. On --d3d9-renderer=software it gets a device, clears, presents once, and then:

[API] IDirect3DSurface9_GetDC
[1658] EIP=0x075030d8 EAX=0x8876086c ...
[API] IDirect3DSurface9_GetDC
[eip-zero] guest called through NULL at batch 1764
  dbg_prev_eip=0x0041c100

EAX=0x8876086c is D3DERR_INVALIDCALL: the second IDirect3DSurface9::GetDC on the back buffer fails, Pawn calls through the NULL HDC it gets back, and the guest dies at 0x0041c100. The window is left showing its caption and menu over an empty grey client area (pawn-software-canvas.png). Adding --d3d9-programmable changes nothing. Pawn draws its whole board through that GetDC, so this is the one call that has to work for it.

Repro commands

Every run: --no-build, --quiet-api, --no-close, an explicit --max-seconds. GPU arms need nohup caffeinate -d -u -t 2400 & first. O=build/d3d-backend-coverage.

mw3 — route copied from test/test-mw3-gameplay.js (abbreviated $ROUTE below; it is the 33-event list in that file plus 950:png:$O/mw3-<mode>-canvas.png):

node test/run.js --app=mw3 --no-build --no-threads --copy-superops \
  --quiet-api --quiet-blocks --batch-size=200000 --max-batches=1020 \
  --max-seconds=220 --no-close --dx-slot=5 \
  [--headless-gl --d3dim-gpu] \
  --png=$O/mw3-<mode>-surface.png --input="$ROUTE"

gta2_demo

node test/run.js --app=gta2_demo --no-build --quiet-api --quiet-blocks --no-close \
  --max-batches=4500 --batch-size=1000 --max-seconds=180 --dx-slot=7 \
  [--headless-gl --d3dim-gpu] \
  --input=3000:di-keydown:13,3100:di-keyup:13,4400:png:$O/gta2_demo-<mode>-canvas.png \
  --png=$O/gta2_demo-<mode>-surface.png

mcm — route from docs/re-notes/motocross-madness-demo.md:

node test/run.js --app=mcm --no-build --quiet-api --quiet-blocks --no-close \
  --max-batches=200000 --max-seconds=300 --stuck-after=100000000 --dx-surfaces \
  [--headless-gl --d3dim-gpu] --png=$O/mcm-<mode>-surface.png \
  --input='200:dlg-cmd:1,10000:keydown:65,10005:keypress:65,10010:keyup:65,10400:mousedown:221:236,10440:mouseup:221:236,12000:mousedown:445:45,12040:mouseup:445:45,14000:mousedown:445:45,14040:mouseup:445:45,24000:mousedown:338:442,24040:mouseup:338:442,26000:mousedown:338:442,26040:mouseup:338:442,28000:mousedown:338:442,28040:mouseup:338:442,60000:png:'$O'/mcm-<mode>-early.png,185000:png:'$O'/mcm-<mode>-canvas.png'

pawn — route from test/test-pawn-directinput7-gameplay.js:

# gpu
node test/run.js --app=pawn --no-build --headless-gl --screen=1024x768 \
  --max-batches=9000 --max-seconds=120 --quiet-api --no-close \
  --png=$O/pawn-gpu-surface.png \
  --input='800:mousemove:552:409,1000:mousedown:552:409,1200:mousemove:552:365,1400:mousemove:552:321,1600:mouseup:552:321,1800:mousemove:900:600,8800:png:'$O'/pawn-gpu-canvas.png'
# software (crashes at batch 1764)
node test/run.js --app=pawn --no-build --d3d9-renderer=software --screen=1024x768 \
  --max-batches=9000 --max-seconds=120 --quiet-api --no-close \
  --png=$O/pawn-software-surface.png --input=<same>

DX SDK samples (dx_boids, dx_twist, dx_tunnel, dx_globe, dx_flip3dtl, dx_viewer)

node test/run.js --app=<id> --no-build --quiet-api --quiet-blocks --no-close \
  --max-batches=3000 --batch-size=100000 --max-seconds=60 \
  [--headless-gl --d3dim-gpu] --dx-surfaces \
  --png=$O/<id>-<mode>-surface.png --input=2900:png:$O/<id>-<mode>-canvas.png

What this contradicts in the 2026-09-19 sweep

docs/d3dim-gl-sweep-2026-09-19.md is not wrong about its own arms; it is measuring something narrower than its table reads as.

  1. "dx_boids / dx_flip3dtl IDENTICAL, 0% diff" is two blank screens agreeing. Measured here with tools/png-inspect.js stats: dx_boids' composited frame is one distinct colour (#000c18, 307200 px) on both backends, while the GPU executor reports 84,869 draws and 5,760,818 triangles. dx_flip3dtl is two colours — black plus its yellow text overlay — with 115,686 GPU draws. The triangles are being drawn and never reach anything the user sees. An IDENTICAL verdict on a one-colour frame is not evidence of agreement, and this is the largest thing the sweep's diff-only method cannot see.
  2. "mcm NODRAW" was a modal, not an absence of 3D. MCM greets a fresh profile with a "we must now test your video memory" MessageBox; a startup slice never gets past it. 200:dlg-cmd:1 plus the re-note's route reaches a live race on both backends. Same for mw3, which the sweep scored IDENTICAL from a menu and which actually disagrees by 4.07% at gameplay.
  3. "eleven of sixteen never drew a triangle" is the right headline but the wrong diagnosis for at least two of them. dx_globe and dx_viewer do not fail to draw — they die on a modal D3DRMERR_BADFILE (sphere3.x, camera.x) and then STUCK in a thunk. That is the known .x file loader asset gap, not a renderer question, and no budget or route fixes it.
  4. The sweep's headline number for GTA2 was menu text; at gameplay it is 1.59% at tol=32 against a bit-identical null band. Both arms show the Wild Demo playfield, so this is a fidelity difference, not a coverage one.

Blocked, and why

Method notes worth keeping