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=...
--isobecause the game checks for its CD and readsd:\video\*.bikfrom it.--memory-mb=1024: at 512 MB the sparse arena runs out while DirectShow builds the title-music graph ([heap] OOM ... sparse arena), and the game showsMusic Error: Can not play file ... morrowind title.mp3then exits.--headless-gl: Morrowind renders through D3D8 on the GL backend.--wasm-enforce-bounds-checks(a node flag) only when chasing a SIGBUS: the headless-gl native module installs a segfault handler that turns a wasm out-of-bounds access into SIGBUS with no stack. The flag makes it a JS trap with a wasm stack. It costs roughly 3x.
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:
The driver:
node test/binaries/candidates/morrowind/drive-morrowind.js OUTDIR 2 1500. It is gitignored like everything else here. Its screenshots areOUTDIR/world-N.png.--tick-ms-per-batch=2for the whole run, plus--control-stdin. The driver watches which files the game reads and reacts:file the game reads driver's mode what the driver does a .bikin bootvideo ESC pulse every 3 s mw_logo.bikmenu alternate ESC and a DI click, which lands on New splash\*.tgaafter the menuworld load nothing, just wait mw_intro.bikintro ESC pulse every 3 s music\explore\*world take the screenshots Do not treat
.bsareads after the menu as a world load. The menu itself readsmorrowind.bsa, and treating that as "loading" made the driver stop clicking.Timeline at tick 2 (~5000 batches/s):
batch event 13.6k Bethesda logo, skipped at once 515k mw_logo/ menu539k New 568k mw_intro, skipped570k in world The WebGL D3D9 backend used to reject most world draws, so the world showed as a bare fog clear with a crosshair. Two fixes (both in
test/test-d3d9-webgl-partial-mips.js):- Partial mip chains. Morrowind's DDS textures stop above 1x1, and
lib/d3d9-backend.jsthrewincomplete mip chain. It now clamps withTEXTURE_MAX_LEVELon WebGL2, and box-filters the missing levels on WebGL1 (headless GL). - Lit draws with no NORMAL.
lib/d3d9-fixed.jsthrewmissing vertex semantic 3:0. The normal now reads as zero, as it does on D3D9.
- Partial mip chains. Morrowind's DDS textures stop above 1x1, and
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:
- ~30% msvcrt
_stricmp(runtime0xbb37cb), a byte-at-a-time loop, called from - ~20%
Morrowind.exe0x4b47e0, a linear list walk that looks up objects by name (vtbl+0x20GetID, then_stricmp, then next). Every lookup walks the list, so the load is O(n^2) in the game's own code. - ~5%
0x6e2e60, texture conversion one pixel at a time.
_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:
- A run whose log has
[gl] context creation FAILED ... No suitable displaydoes not count. GLFW found no monitor, nothing renders, and it reaches the menu at 385k because it skips all drawing. - Suppressing the per-call API log is not a lever. Each API call crosses
into JS twice (
log,log_api_exit). Gating both behind a wasm flag measured 84.3 s against 80.7 s ungated with the same_stricmpbuild on a quiet box, which is noise, so it was not kept.
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)
CoCreateFreeThreadedMarshaler+ FTM inner/IMarshal objects (quartz).ReplyMessage(one pending reply per cross-thread send depth).- COM in-proc DLL loads requested from a cooperative worker thread (yield 3)
were never serviced — T3 parked forever.
run.js/host.jsnow service them and sync main's DLL/thunk globals into the worker first (adoptMainGlobals). $load_dllrebases an image that would overflow the low guest spans into a sparse reservation. Before,l3codecx.axwas placed at0x45e6000and its image overwrote emulator tables (PAGE_INDEX_ARENA), which surfaced as0xCAC4BAD0dispatch garbage. quartz/devenum/l3codecx now load near0x7e8x_xxxx.- Import-name logging in
$win32_dispatchusedGUEST_BASE + rvainstead ofg2w(image_base + rva)— out of bounds for a rebased DLL. $static_sys_dll_name_atstops at the list terminator; a rebased DLL's handle is above the pseudo-handle base and used to walk off the list (GetModuleFileNameA).
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.md —
cmp/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:
- The dev-server needs
--isolate. Without the COOP/COEP headers the page reports[threads] not cross-origin isolated — running single-threadedand quietly runs the mode you were not testing. - Wait on
WineFrozen.status().hosts, not on console text. The worker-hosted main thread never prints the cooperative path's "DLLs ready" / "Starting run", and a frozen host prints nothing until it is stepped.runningAppsis module-scoped inbrowser-shell.js, not a global. - The ~5930-file mount takes a minute or two before any of this; give it its own budget or it eats the stepping one.
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.js → guest-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:
- after the worker loads the PE, main's count is 0 while the worker's is N,
and the first
CoCreateInstancesearches zero rows whatever is mapped; - after the worker loads a COM server, main's count still does not move, so the retry stops short of the row just written and asks for the same DLL again. Forever.
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
- Phase A is what a user actually sits through, and it is a video codec being interpreted at 2/3 x87 dispatches.
- Phase B is the game's own quadratic name lookup plus emulator block transfer.
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
- ESC in the world opens the pause menu, and a blind DI click closes it.
The "two opposite inputs, same result" reading above is wrong about why: a
DI click with the cursor near screen centre lands on the pause menu's
first item, which is
Return. So a plain click train run through the world silently undoes the pause menus the ESC train opens, and captures alternate between menu-open and clean-world frames for that reason alone. --quiet-api-fastis worth ~25% here and is not mentioned anywhere above: 7,245 -> 9,027 batches/s on back-to-back runs with otherwise identical flags. Morrowind makes ~20M API calls in 85 s, so the name-decode path is a real share of the run.
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.