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.
- "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. - "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:1plus the re-note's route reaches a live race on both backends. Same formw3, which the sweep scored IDENTICAL from a menu and which actually disagrees by 4.07% at gameplay. - "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.xfile loader asset gap, not a renderer question, and no budget or route fixes it. - 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
- dx_globe, dx_viewer —
D3DRMERR_BADFILEonsphere3.x/camera.x. Asset/.x-loader gap (see thedx_viewernote in the DX re-notes), identical on both backends. Not a backend question until the loader lands. - dx_boids, dx_flip3dtl, dx_twist, dx_tunnel — no geometry reaches the composited frame on either backend, so there is nothing to compare. Worth chasing on its own: for boids and flip3dtl the executor is drawing, so the loss is between the render target and the primary, not in either rasterizer.
- pawn on software — crashes (above). The GPU arm is the only D3D9 gameplay capture in this sweep.
- black_white_2_demo — the only other D3D9 title in the registry. Not
attempted:
docs/re-notes/black-white-2.mdalready records that it does not reach gameplay, so it cannot produce a gameplay row on either backend yet. - darkstone_demo, heroes3_demo, jazz2_demo, aoe2, captain_claw_demo, spider,
pocket_tanks, halflife_uplink, deus_ex_demo — not covered here. Each needs
either a multi-minute
--control-stdinroute (darkstone) or is a 2D DirectDraw title whose D3DIM draw count was already zero; both are a second pass, not a budget increase. deus_ex_demo ships configured forSoftDrv.SoftwareRenderDevice, so it is not a D3D backend comparison at all.
Method notes worth keeping
--pngprefers a DirectX surface over the composited desktop and will show a perfect 3D frame while the window is empty grey (that is exactly Pawn's old failure mode). TheN:png:FILEinput action always captures the composited screen, so one run yields both:--png=…-surface.pngplus--input=N:png:…-canvas.png. Where the two differ, the canvas is the honest one. In this sweep they agreed for every app that drew.- Judge "did it draw" with
node tools/png-inspect.js stats <png>— distinct colour count — before reading any diff percentage.--dx-surfaces'scolors=column is sampled and reported 1 for frames that turned out to be genuinely uniform, but it is a sample, not a census. - Diff a textured 3D frame at
--tolerance=32and always alongside that app's own null band. At tolerance 0, MW3's software arm differs from itself by 33%.