Morrowind (GOTY retail ISO) — local-only candidate

Retail, so localhost only: morrowind is in LOCAL_CANDIDATE_APPS (the dropdown index.html shows only on localhost), never DESKTOP_APPS. Nothing made from the disc is committed, and tools/deploy-berrry.js refuses test/binaries/candidates/morrowind/ and downloads/ by name, --files= included (test/test-deploy-morrowind-local-only.js). You bring your own disc:

node tools/prepare-morrowind.js --iso=path/to/your/Morrowind.iso   # needs unshield + 7z
node test/run.js --app=morrowind --memory-mb=1024 --headless-gl --quiet-api \
  --tick-ms-per-batch=2 --stuck-after=100000 --no-close --max-seconds=170

The prep tool unpacks data1.cab (unshield) and DirectShow out of DX81eng.exe (7z) into installed/, copies Video\* + the autorun files into cd/ with a one-track cue over the image (D: is a CD-ROM labelled MORROWIND), and writes .wine-assembly-browser.json. That manifest also carries the 432 registry keys regsvr32 /s writes for devenum, quartz and l3codecx, in that order: registering quartz first misses the filter-mapper Instance keys. Both hosts import those keys at every launch. Without them the title music fails with "Music Error: Can not create filter graph." The browser path is unverified. It fetches the whole ~900 MB tree eagerly.

Reproduce (manual, pre-entry)

I=test/binaries/candidates/morrowind/installed
node test/run.js --exe="$I/Morrowind.exe" --dll-seed=binkw32.dll --vfs-include='**' \
  --iso=downloads/Morrowind.iso --reg-import=test/binaries/candidates/morrowind/registry.json \
  --memory-mb=1024 --headless-gl --quiet-api --stuck-after=100000 --no-close \
  --max-batches=9000000 --max-seconds=570 --input=...

Reaching gameplay (2026-09-19)

Reached: the prison-ship hold with Jiub, textured, lit and animating. About 2k batches after chargenname1.mp3 the game shows its "Name" text-entry prompt (label, caret, OK) over the world, then waits for input. That is the end of what the driver automates. Before the mip-chain fix the prompt drew as an empty frame on a fog clear: the UI font textures hit the same rejected draws. What gets there, in one run of about 3.5 minutes of wall clock:

What the startup load spends its time on

The load is compute-bound: 79% of batches spend their whole block budget and only the main thread runs. From the block histogram at batches 100k-200k:

_stricmp is now bound to the native handler even though the real msvcrt.dll is loaded ($native_override_export_api_id in 08b-dll-loader.wat, like _ftol). The measurement is fixed work, boot to menu at tick 2, with GL up. The driver quits when it reaches the menu, and the CPU is user from /usr/bin/time:

arm batches to menu user CPU load avg
without the override 523k 97.9 s 6
with the override 441k 74.7 s 5

So the override is about 24% less CPU and 16% fewer batches. It makes ~9.8M native _stricmp calls before the menu (16.6M API calls in total against 6.8M). Splash order is random, so a few thousand batches of it is noise.

Timing on this box is only worth quoting at a low load average. At load 26-61 the same two arms swapped places in wall-clock time from one run to the next.

Two traps:

The driver's MW_STOP_AT=menu MW_NO_TRACE=1 env switches do the fixed-work run. MW_NO_TRACE takes the [FS] ReadFile lines the driver keys on from --trace-fs instead of --trace-api. Without either, the driver sees no reads and sits in Bink forever.

Dropping msvcrt.dll entirely is not an option. Morrowind imports C++ exception handling from it (__CxxFrameHandler, the exception class members), and quartz.dll and devenum.dll import it too.

The emulator heap (2026-09-19)

With _stricmp gone, the next cost in any load (the startup load, and the cell load right after entering the world) was the emulator's own heap: $heap_arena_find 21% and $heap_alloc 20% of CPU over batches 150k-250k. $heap_alloc walks one first-fit free list and validates every link through $heap_arena_find, which scanned the whole arena table each time. The lookup now tries the arena that answered last before scanning. Same fixed work (boot to batch 250k, tick 2, GL up), user CPU:

arm run 1 run 2
HEAD 51.3 s 51.2 s
arena hint 30.0 s

About 41% less. Left afterwards: $heap_alloc 5.4%, $heap_arena_find 1.2%, so size-class free lists would not buy much more.

The GPU sync per draw

Past the cell load, the world window (batches 560k-600k) is almost all native GL: 37-38% of CPU is one headless-gl native frame. lib/d3d9-host.js called gl.finish() after every command, so each draw stalled the pipeline. It now drains only where something observes the result (fence, readback, release, reset, a failed command) and flushes at Present. Same wasm, only d3d9-host.js differing, CPU profile of the fixed world window:

arm sampled CPU, batches 560k-600k in the GL sync frame
finish every command 68.6 s 26.6 s (38.8%)
finish at observation points 44.0 s none

About 36% less in the world. What was left there was mostly JS: the command stream's payload copy (d3d-command-stream.js copy, 18%) and run.js's h.log (19%, plus 8.6% in its callback). h.log is called by $win32_dispatch for every API call, and for a COM method it resolved the name with a linear apiTable.find over ~3700 entries. Nearly every call in the D3D8 world is COM. It is now an index lookup. This is CLI only; the browser has no such hook.

Every MW_NO_TRACE run, on every build, puts up a Warning box a few thousand batches into the world: Model Load Error: Meshes\a\A_Imperial_UA_Pauldron.nif cannot load file. The box draws with no message text. Neither is investigated yet.

The profile tooling: the driver's MW_ROOT runs a worktree's run.js, and MW_MAX_BATCHES ends the run at a fixed batch (run.js takes the first --max-batches, so passing one through MW_EXTRA does nothing). Wake the display (caffeinate -u -t 3) before each run, or GLFW finds no monitor. tools/cpuprof-top.js --names resolves names from build/combined.wat beside it, so a profile of a pinned WINE_ASSEMBLY_WASM needs that build's combined.wat, or every name is wrong.

Boot sequence (default 200 ms/batch, 1000-block batches)

batch what
~7k bethesda logo.bik (Bink, has audio)
150k-250k loading bar over splash_*.tga (splash picture itself draws light blue — not investigated)
~560k mw_logo.bik, then title music via DirectShow
~600k main menu: New / Load / Options / Credits / Exit
click di-mousedown:1/di-mouseup:1 with no move lands on New (the DI cursor starts at screen centre, 320,240)
+70k world load (splash TGAs, morrowind.bsa/.esm reads), then mw_intro.bik

The Bink clock problem (the main blocker)

Bink paces video against wall time (timeGetTime/QueryPerformanceCounter and the DirectSound play cursor). At the default 200 ms/batch, decoding one frame costs more guest time than the frame lasts, so Bink is permanently behind: it decodes and skips forever and never presents, with zero API calls from the main thread (main loops inside binkw32 +0xd100..0xd1db, the bitstream decoder). The picture is byte-identical for 300k+ batches. This hit the Bethesda logo in some runs and mw_intro.bik in every run.

ESC only skips a video when it lands while the decoder is between frames, so ESC schedules are luck, not a fix. What works is a slower guest clock: --tick-ms-per-batch=2 plays the logos visibly. --input=B:tick-ms:N switches the cadence mid-run so the boot can stay on the fast clock (added for this app).

Large batches (--batch-size=10000 --tick-ms-per-batch=20) give the same ops-per-guest-ms with fewer batches, but PNG captures of the D3D8 frame came back as the empty GDI window intermittently at that batch size — use the default batch size when a picture matters.

Music restarts every ~1000 batches — not a bug

After the intro, exploration music (music\explore\mx_explore_N.mp3) opens a new DirectShow graph about every 1000 batches. A 3-minute track at 200 ms/batch is ~900 batches, so the tracks really are ending. It does mean many graph builds per run, which is part of why 512 MB runs out.

Emulator fixes this app needed (2026-09-18)

Threads

binkw32 starts two worker threads that sit in WaitForSingleObject at binkw32 +0xd3c3 (runtime 0x8e13c3 when binkw32 loads at 0x8d4000, origBase 0x30000000). quartz threads appear and exit per graph; one reports EIP=0 (likely call/jmp to NULL) when its thread proc returns — harmless.

What is hot now, and whether "C++ opts" is the lever (2026-09-19)

One window, --handler-hist-thread=0 --handler-hist-start=150000 --handler-hist-stop=250000 over the boot/load phase (tick 2, GL up, --quiet-api), with --hist-json + --hot-block-dump. Counts are load-immune, so the load-16 box is fine for this. 481.9M ops, 77.2M block entries, 6.24 ops/block, 13455 distinct blocks. By module: exe 74.9%, msvcrt 14.0%, binkw32 0.3%.

The single hottest block is 15.33% of every block entry in the window — four times the next one — and it is a C++ container walk:

00479abb   test edx,edx / jz            ; __thiscall, ecx = this, eax = N
00479abf   mov edx,[ecx+0x8]            ; cursor = this->head
00479ac2   mov [ecx+0x10],edx
...
00479acd   dec eax                      ; <-- the hot block, self-looping
00479ace   mov edx,[ecx+0x10]           ; cursor
00479ad1   mov edx,[edx+0x4]            ; cursor = cursor->next
00479ad4   mov [ecx+0x10],edx           ; write it back to the object
00479ad7   jnz 0x479acd

Seek-to-index over a singly linked list, with the cursor spilled to this+0x10 on every step. It has no static xrefs (tools/xrefs.js), so it is reached through a pointer — it is the walk behind the 0x4b47e0 O(n^2) name lookup already recorded above. Next after it: 0x6e2e8d and 0x6f1bb4 at 4.07% each, the per-pixel texture conversion (byte loads, shr/shl by CL, movzx), then a flat tail of ~1.3% blocks.

Handler ranking for the window: $th_load32_rop 12.0%, $th_load8_ro 10.4%, $th_inc_r 5.3%, $th_store32_rop 5.3%, $th_push_r 5.2%.

So: yes to one C++ idiom, no to the obvious ones. Folding vtable dispatch, thiscall prologues or push/pop runs is control flow, and this project has already measured that class at ~0 twice (case_chain, dispatch replication's arm H). 0x479acd is the opposite: a memory idiom, which is the class that has paid here (rect_run +12%, RLE_RUN +7%).

It is also a true self-loop block, so $loop_match_block in src/07b-loop-match.wat already sees it and declines it. tools/match-loops.js --why on this exe: 5224 self-loops, 2.3% matched, and 29 non-streamed-load declines — the existing COPY/FILL/LUT/SCAN predicates all require an affine address stream, and a pointer chase has none. The extension is a fifth predicate: a load whose address is fed by the value the previous iteration loaded, with one counter and one store back to a fixed slot.

Arithmetic before building anything: 11.8M block entries and ~59M ops of the window collapse to ~11.8M handler iterations inside one block entry. At the bench-loops.js prices (~8 ns a dispatch, ~9 ns a block transfer) that is the largest single interpreter item Morrowind has. But the chase is serial dependent loads, so the real ceiling is memory latency, not dispatch, and the fold's win will be smaller than the op count suggests.

Not yet evidence: this is ONE window, and it is a loading window. The hot-loop-census.js rule applies — a region at 15% in one window and absent in the next is a scene, not a fold target. Take the in-world window (560k-600k) and a menu window before writing any WAT, and remember the world window is already 37-38% native GL, so the same fold is worth much less there.

CORRECTION: that block is a scene, not a fold target (2026-09-19, later)

The 15.33% above is one loading window, and it does not survive more of them. Two independent harnesses now say so.

Six consecutive CLI windows, one run, 750k batches to the post-"New" intro (--handler-hist-thread=0,0,0,0,0,0 --handler-hist-start=150000 --handler-hist-stop=750000 --hist-json=, with 620000:di-mousedown:1 to click New):

window batches what it is exe+0x479a80..479ad9
w1 150k-250k splash + bink 8.0%
w2 250k-350k loading 12.9%
w3 350k-450k loading 0.3%
w4 450k-550k menu 6.6%
w5 550k-650k menu 3.2%
w6 650k-750k mw_intro.bik 0.0%

Mean 5.2%, spread 12.9pp. And twelve windows taken in a real Chrome (below) never had 0x479acd in the top forty blocks at all.

tools/hot-loop-census.js returns the same verdict on both sets:

NO region holds >=5% of block entries in every window. Nothing here is a safe fold target on this evidence.

The list walk is real and recurring, but it is a load-time name lookup, so it is big exactly when the game is reading assets and absent when it is playing a video. This is the SimGolf/H455 trap the census tool was built for, and it was one window away from being repeated here. A Morrowind-specific fold is not the next work item. The cross-app levers in hot-idiom-census-2026-09-19.mdcmp/test + Jcc fusion, then the zero-then-load8 / load8+inc pair — are, because they pay in every window of every app rather than in one scene of this one.

Still not measured: gameplay proper. w6 is the intro movie; the world window is past it.

Driving it in a real browser (frozen mode)

?app=morrowind&frozen&debug plus window.WineFrozen.step(n) runs the browser build under an agent's control, which is the only way to reach the Worker-hosted code path (--threads in the CLI keeps the guest's MAIN thread in-process; the browser puts it in worker slot 0). Three things that cost a session each:

Morrowind at 16ms/step was still on Initializing Data… after 26,000 steps (390s of guest time), so browser windows reach the loading phase only.

The Worker-mode COM reload loop (2026-09-19)

Threads mode in the browser dies a few seconds in:

[COM] Loading DLL: quartz.dll (worker)      x~30, each ~0x130000 lower
[COM] DLL load error: DLL table capacity 32 exhausted
[threads] guest trapped in worker: unreachable @ EIP=0xf523d7

com_create_instance (lib/storage.js) resolves the server through a fixed ctx.exports:

const dllCount  = exports.get_dll_count();
const dllTable  = exports.get_dll_table();
const imageBase = exports.get_image_base();

In browser worker mode that is host.js's idle instance — the one that never loaded the PE. dll_count is a per-instance global, so the row the worker just appended is invisible to it, and its image_base is 0, so the export-name reads would be wrong even if the count were right. Every retry returns CO_E_DLLNOTFOUND, the guest yields 3 again, and _handleComDllLoadThreaded (host.js) maps another copy — until the shared 32-row table is exhausted and the thread runs into garbage.

a4bd125c fixed the CLI shape of this (_publishLinkLoaderState raises main's count after a worker load) but the browser reaches the loader through host.jsguest-thread-host.js comLoadDll, which never publishes. The right fix is probably to resolve against the calling link rather than a fixed instance, since raising the count alone still leaves image_base 0.

It reproduces only at full speed: 1300 frozen steps never trapped, and the first unfrozen seconds did. It is a race, so a frozen bisect will not find it.

FIXED, cf4d4000 (2026-09-19)

The guess above about image_base was wrong, and the real cause is narrower. image_base is seeded on the idle instance — loadPe in worker mode already follows up with init_thread(7, meta.get_image_base, ...). What is not carried is $dll_count. It is a per-instance global, init_thread does not take it, and nothing published it, so:

a4bd125c fixed the CLI shape of this in ThreadManager._publishLinkLoaderState, called from its own yield loop. host.js services the guest-main worker's COM and LoadLibrary yields directly and never enters that loop, which is why the browser kept the bug. It now publishes after both, and seeds the count once after loadPe.

Verified in Chrome (?frozen + Threads, tools/dev-server.js --isolate): each of quartz.dll, devenum.dll and l3codecx.ax now loads exactly once (quartz was ~30x), no DLL table capacity 32 exhausted, no trap, and the guest gets as far as creating its ActiveMovie Window.

Phase-separated profiles (2026-09-19)

One window is not evidence about where an app spends its time, and on this title it is actively misleading: the 15.33% block recorded further up came from a window that turned out to be a video. So each phase below is profiled on its own and labelled by its own PNG rather than by a batch number copied from another run.

Recipe (CLI, tick 2, --headless-gl, --quiet-api, GL confirmed up — a [gl] context creation FAILED run does not count):

ESC=$(for b in $(seq 6000 1500 528000); do printf "%d:keydown:27,%d:keyup:27," $b $((b+40)); done)
node test/run.js --exe="$I/Morrowind.exe" --dll-seed=binkw32.dll --vfs-include='**' \
  --iso=downloads/Morrowind.iso --reg-import=.../registry.json \
  --memory-mb=1024 --headless-gl --quiet-api --stuck-after=100000 --no-close \
  --tick-ms-per-batch=2 --max-batches=9000000 --max-seconds=780 \
  --handler-hist --handler-hist-thread=0,0,0,0,0,0 \
  --handler-hist-start=380000 --handler-hist-stop=530000 --hist-json=OUT/w \
  --input="$ESC,385000:png:OUT/p385000.png,..."

The ESC pulse is load-bearing. Without it the run never leaves phase A. This replaces the gitignored drive-morrowind.js, which is missing from the tree; it does not watch which files the game reads, it just pulses.

Timeline at tick 2 with the pulses: splash from before 380k to ~515k, menu by 527k (cursor on New).

Phase A — Bink intro video

Unskipped, this phase does not end. At batch 249k the Bethesda logo is still on screen — the entire 250k-batch budget went into one logo.

window ops block entries ops/block distinct blocks
50k-100k 481.8M 53.9M 8.93 1514
100k-150k 3085.9M 200.0M 15.43 246
150k-200k 3614.56M 200.0M 18.07 214
200k-250k 3614.55M 200.0M 18.07 214

The last two windows are the same work twice: same 214 blocks, op counts 0.0002% apart, shares equal to 0.1pp. Every hot region is binkw32, and binkw32+0x3000cb50..3000d306 alone is 81.4 / 76.5 / 76.5% of block entries. This is the one region in this app measured stable across consecutive windows — the opposite of exe+0x479acd, which is retracted above.

Handlers, 200k-250k window:

handler share of dispatches
$th_fpu_mem_ro (H190) 37.61%
$th_fpu_reg (H189) 22.43%
$th_fpu_mem (H188) 6.51%
$th_compute_ea_sib (H149) 6.30%

66.6% x87. Adjacent pairs: H190->H190 23.63%, H189->H189 11.34%, H190->H189 9.72%, H189->H190 9.29% — 54% of all dispatch transitions are x87 to x87. The SIB census is the same story: H188 in [reg+reg*4+disp] forms is over 70% of recorded SIB effective addresses.

Do not read that as "fuse H189/H190". An x87 fusion was already built and measured on Monkey Island and gave nothing, because the cost is inside the helper calls, not in the dispatch between them; what paid there was moving the x87 registers into the per-thread FPU_FILE (-16%). The finding here is that the phase is x87-bound, which says to make those three handlers cheaper, not to glue them together.

Phase B — asset load, "Initializing Data..." (~380k-515k)

Six 25k windows, all showing the splash with its progress bar. ops/block is 2.45-9.32 against phase A's 18.07 — this phase is block-transfer-bound, the opposite shape, and a fold that helps one cannot help the other.

region w1 w2 w3 w4 w5 w6 mean spread
exe+0x4b47e0..4b48a5 1.0 0.0 22.4 51.9 30.3 0.0 17.6% 51.9pp
exe+0x4db590..4db5d0 0.1 0.0 16.5 30.6 16.7 0.0 10.6% 30.6pp
exe+0x4d10eb..4d1337 24.1 22.7 7.1 0.0 0.0 0.0 9.0% 24.1pp
exe+0x4a45f0..4a480c 18.5 17.2 5.4 0.0 0.0 0.0 6.8% 18.5pp

0x4b47e0 is the object-name list walk already identified above as O(n^2) in the game's own code. It peaks at 51.9% of block entries and is absent from two of the six windows, so even within one phase it is a sub-scene. hot-loop-census.js verdict for phase B: no region holds >=5% in every window.

Phase C — menu, and what is still unmeasured

The menu is up at 527k. It has no window of its own yet: the last window (505k-530k) straddles the tail of mw_logo.bik and the menu, which is why binkw32+0x30011d80 reappears at 25.3% there.

Gameplay is still unmeasured, but not for the reason written here originally — the click on New works. See "Driving to gameplay" below for where it actually stops.

So "the load" is two unrelated problems

Quoting one number for "load" hides both.

Driving to gameplay — how far the CLI gets, and the exact wall

Five headless runs (2026-09-19) chasing a gameplay histogram window. None reached gameplay, but they moved the blocker a long way and ruled out three input paths. Recorded here so the next session starts from the wall rather than from the intro.

The world renders

The DirectInput click on New works. With

--input="<ESC train>,<DI click train>,..."
  ESC train:  seq 6000 1500 <end>   as B:keydown:27 + B+40:keyup:27
  DI click:   seq 520000 3000 560000 as B:di-mousedown:1 + B+60:di-mouseup:1

the game runs mw_intro.bik, tears it down and builds the prison-ship hold. By batch ~810k the captures show the hold textured, lit, with Jiub in frame — i.e. the D3D/GL path, the world geometry and the character model all work. This is much further than the notes above assumed.

ESC opens the pause menu and nothing we send closes it

The ESC pulse train is load-bearing for skipping the Bink videos, but any pulse that lands after the world is up opens the in-game pause menu (Return / New / Load / Options / Exit — note Return, which the main menu does not have). It is not a toggle in our build:

run ESC after 860k frame at 870k-1040k
prof4 none pause menu, open
prof5 one pulse at 866k pause menu, open

Two opposite inputs, same result, so a corrective press is not the fix. End the train before the world appears: seq 6000 1500 800000 is enough to skip every video and leaves no menu.

The real wall: the chargen Name box

With the train ending at 800k the run reaches the character-generation Name text prompt, drawn over the live world, by ~810k — and sits there. The frame is byte-identical from 810k through 900k.

Three input paths tried against that box:

path result
keydown/keyup letters field stays empty through 880k — never reaches it
di-keydown/di-keyup letters destructive — chargen torn down, back at the main menu by 834k
keypress letters untested to completion

So do not use di-keydown for text. It is for held game keys (movement); feeding letters through it loses the session.

Getting past the box is the whole remaining problem. Likely candidates for next time: keypress (the WM_CHAR path), or clicking the box's own OK button — it sits at roughly (447,262) in the 640x480 capture, with the cursor parked near (325,250), so a corner-slam relmousemove followed by a known offset and di-mousedown:1 is a deterministic way to hit it.

SOLVED: the Name box clears, and the world resumes

That suggestion is the answer. Corner-slam the cursor, move it to the OK button, click with DirectInput, and repeat the whole thing every 4000 batches:

B:relmousemove:-2000:-2000,
B+200:relmousemove:447:262,
B+300:keypress:74,
B+400:di-mousedown:1,
B+500:di-mouseup:1

for B in seq 610000 4000 780000. The box is gone afterwards and every subsequent capture is a distinct hash, so the world is animating again. The run reaches the prison-ship hold — textured and lit, Jiub standing in frame, camera live — at batch ~636k of an 85-second headless-GL run. Pressing ESC there opens the in-game pause menu (Return / New / Load / Options / Exit), which is independent proof the world is live and taking input.

A keypress:74 was sent before each click and was not isolated from the click, so which of the two clears the box is still unknown. The click is the one with a mechanism behind it.

That is character generation, not gameplay: no HUD, and the player has not been released from the census office. Two NPC conversations and a walk out of the hold remain, which is wall clock, not a blocker — at the measured 9.4k batches/s the world appears at ~605k batches, around 65-85 s, leaving nothing in a 90 s budget. Give this one run 200-240 s and a real gameplay frame is in reach.

Two more things the earlier runs got wrong

A fixed-batch --input schedule is not reproducible for this app

Batch numbers are reproducible for a fixed input schedule; how many batches fit in a fixed wall-clock budget is not. Across runs on one box the rate ranged 6,836-9,497 batches/s (+-33%), so the same 85 s reached anywhere between batch 604k and 807k. That is why an ESC train ending at 600k skips mw_intro.bik in one run and lets it play in the next. Either repeat every action across a wide batch span, or drive live with --control-stdin / tools/ctl.js; do not aim a single action at a single batch.

Build the train with seq -f "%.0f". BSD seq switches to %g past a million and prints 1.02e+06; run.js parses that batch number as 1, so every action above batch 1M fires immediately and silently. A run built with a bare seq loses its entire walk train and parks on whatever screen the sub-1M actions left up — which looks exactly like the app being stuck.

Phase timeline on the current build at --tick-ms-per-batch=2 with a 700-batch ESC pulse spacing (denser than the 1500 used above): main menu up at 460k-545k, New click then world-load splash ~30k batches later, mw_intro.bik next, world by ~605k when the ESC train covers the intro.

One run put up [MessageBox] "Error": "Music Error: Can not play file. Data Files/Music/Special/morrowind title.mp3" even at --memory-mb=1024. It did not block progress and did not recur.

Cheap oracle: md5 the capture series

Every one of these runs was diagnosed by md5 -q <dir>/*.png | sort | uniq -c. A single hash across a 170k-batch span means the guest is blocked on a modal, not running slowly — and it costs nothing next to reading a histogram that is really measuring a paused game. Do this before running hot-loop-census.js on any window, otherwise the census faithfully profiles a still frame. It is also what caught the regression in the table above: prof8's hash differed from prof7's, which is how the DI-key teardown was spotted at all.

Runs are deterministic (see project_headless_determinism), so a batch number learned from one capture series is reusable in the next run.