StarCraft shareware

test/binaries/candidates/starcraft-shareware/installed/, registry id starcraft_shareware (lib/apps.js).

DirectDraw present counter investigation (2026-08-31)

The browser HUD label PRESENT N/s is not a browser FPS counter and it is not proven to be StarCraft's logical game-frame counter. In the current runtime it counts PerfHud.guestFrame() calls from host.js when h.dx_trace receives DirectDraw trace kinds 5 or 6. The browser upload path is separately backpressured: WineHost._presentDxIfDirty() coalesces dirty DirectDraw work through requestAnimationFrame, using _dxFrameSeq/_dxPresentedSeq, so many guest-side present events can collapse into one page-frame upload.

For StarCraft, the observed steady DirectDraw path is primary-surface Lock/Unlock, not Flip. A bounded CLI API trace:

timeout -s KILL 120 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --max-batches=3500 --batch-size=100000 \
  --trace-api=IDirectDrawSurface_Lock,IDirectDrawSurface_Unlock,IDirectDrawSurface_Blt,IDirectDrawSurface_Flip \
  --frame-stats > /private/tmp/starcraft-ddraw-trace.txt

The captured callsite histogram was:

1741 Unlock 0x007a953c
1741 Lock   0x007a901b
   1 Unlock 0x004c7a9d
   1 Lock   0x004c7a1d

No IDirectDrawSurface_Flip appeared in that captured window. The dominant return addresses are in storm.dll, whose runtime base in this run was 0x79c000 and original image base is 0x15000000:

Runtime return Original VA Function entry Meaning
0x007a901b 0x1500d01b 0x1500cfc6 return from vtable slot 0x64, IDirectDrawSurface::Lock
0x007a953c 0x1500d53c 0x1500d4f9 return from vtable slot 0x80, IDirectDrawSurface::Unlock

storm.dll exports these graphics helpers by ordinal. The StarCraft EXE import table maps Storm ordinal #350 to IAT 0x4d54d0, and the local thunk is:

004b5e1a  ff 25 d0 54 4d 00  jmp [0x4d54d0]

Static disassembly of the Storm helper at 0x1500cfc6 sets up a DDSURFACEDESC, obtains the surface pointer from Storm's surface table, then calls [surface_vtbl+0x64] and handles DirectDraw retry/error cases. The helper at 0x1500d4f9 similarly fetches the surface pointer and calls [surface_vtbl+0x80].

On our WAT side, IDirectDrawSurface_Unlock calls host_dx_trace(2, ...), marks CPU writes, and calls $dx_present when the surface is primary. $dx_present emits host_dx_trace(5, ...), which is the event currently shown as PRESENT/s in the HUD. So the high StarCraft PRESENT/s value is best understood as "guest DirectDraw primary-surface present events per second", generated by unlock-driven primary updates. It is not direct evidence of 250 browser compositor frames per second.

Counting frames

There are at least three useful counters, and they answer different questions:

Question Counter Notes
How often does the guest hand us display work? wine.onGuestFrame / PerfHud.guestFrame() / dx_trace kind 5 or 6 This is the HUD PRESENT/s; for StarCraft it is driven by Storm Unlock on the primary surface in the trace above.
How often does the browser actually paint? page FPS / requestAnimationFrame cadence / canvas upload counts This is capped by the browser compositor; DirectDraw events are coalesced before upload.
How often does StarCraft advance simulation or animation? not yet identified Requires finding an EXE-level game/render tick, or measuring visible canvas changes. DirectDraw unlocks are only a proxy.

Browser worker-mode samples reached the Blizzard/Smacker intro and confirmed the separation: page FPS stayed about 60 while DirectDraw events and image uploads were independent counters. Those runs did not reach the in-mission gameplay state shown in the screenshot, so the exact gameplay tick boundary remains open.

Driving to gameplay

The app registry already launches the shareware build with:

ophelia terran1

That takes the binary to the Terran mission path, but the CLI still needs input to skip the early modal/movie/briefing screens. This route reached the same in-mission state as the browser screenshot:

timeout -s KILL 180 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --batch-size=100000 --max-batches=4700 --no-close \
  --repaint-every=50 \
  --input=100:focus-main-window,120:keydown:27,125:keyup:27,300:keydown:27,305:keyup:27,600:keydown:27,605:keyup:27,900:keydown:13,905:keyup:13,1200:keydown:27,1205:keyup:27,3600:mousemove:545:393,3610:mousedown:545:393,3620:mouseup:545:393,4250:mousemove:200:263,4260:mousedown:200:263,4270:mouseup:200:263,4550:png:/private/tmp/sc4550.png \
  --frame-stats > /private/tmp/starcraft-to-gameplay.log

Useful landmarks from that run:

Batch State
3500 Mission briefing/start screen, with "Objectives Destroy the rebel base" and Replay/Start/Cancel buttons.
3600-3620 Click Start at about (545,393); this loads battle.snp and standard.snp.
4200 Gameplay is visible but covered by the "Starcraft Tips" modal.
4250-4270 Click OK at about (200,263).
4550 Unobstructed in-mission gameplay.

Use this gate before counting a candidate game frame. Startup counts mostly measure Smacker and the outer message pump.

Clicks skip the Smacker videos. Escapes do not.

Measured 2026-09-15 by driving a frozen --control session by hand (step, screenshot, decide, step) rather than replaying a fixed --input schedule. Six escapes in a row never left the intro cinematic; the first click ended it immediately.

batch screen action effect
23 Blizzard logo —
120 escape logo dismissed
150 "Blizzard Entertainment Presents"
190-670 intro cinematic (static, cockpit, debris) escape x5 no effect
670 click 320,240 cinematic ends
790 title screen, "Loading"
990 mission card "STRONGARM / Chau Sara" click 320,240
1140 mission briefing, Objectives + Start click 545,393
1540 gameplay behind "Starcraft Tips" modal click 198,261
~1690 unobstructed gameplay, 250 min / 200 gas / 12 supply

That reaches gameplay at batch ~1690 against the escape route's 4550 -- 2.7x less work to get there, and the difference is almost entirely Smacker frames that were being decoded rather than skipped. The escape route above still works; it is just paying for the intro it never manages to cancel.

Pass --repaint-every=50: without it the boot is several times slower in wall clock for the same batches.

0x004b2ed0 is not a live frame counter

Corrected 2026-09-15. The earlier note called it the simulation counter. Read over a driven session it is static at 1394928771 across 300+ batches of gameplay whose screen is demonstrably advancing (tools/png-diff.js reports 833 pixels changed in a 330x172 box over 300 batches). A gate built on it reads a constant and passes anything.

Count block entries at the verifier 0x004411e7 instead. In a live --control session that means scanning the hot-block histogram for the address, since the standard probe only returns the top 40 blocks and the verifier is far below that:

// node tools/ctl.js -s :PORT eval '<this>'   (after reset_handler_hist +
// set_handler_hist_enabled(1) and the window you want to measure)
(function(){var e=exports,m=new Uint32Array(memory.buffer);
 var bb=e.get_hot_block_hist_base()>>>2,bc=e.get_hot_block_hist_count()|0;
 var tot=0,want={},targets=[0x4411e7,0x4cbaf0];
 for(var j=0;j<bc;j++){var ad=m[bb+j*2]>>>0,hh=m[bb+j*2+1]>>>0;
  if(ad&&hh){tot+=hh;for(var k=0;k<targets.length;k++)
   if(ad===targets[k])want[targets[k].toString(16)]=hh;}}
 return JSON.stringify({blockHits:tot,found:want});})()

Early-mission baseline from that probe: 82,818 block entries per game frame, 1.16 display flushes per game frame (38,096,358 blocks / 460 frames / 535 flushes). For comparison a real iPhone measured 137k-168k blocks per frame early in a match and 493k late in one.

--no-threads is mandatory for any of these probes. With real threads the guest runs in a worker with its own wasm instance and controlEval reads the main instance, whose counters then sit at zero -- the same trap that made a phone session report cache_stores: 0 beside a frozen thread_alloc.

The hot clusters reproduce exactly in this route (storm at 0x79c000): 7c108b/97/ad/ce = cluster A at 15.68% of block entries, 7c056a/0511/0558 = cluster B at ~7.3%, 4b48aa = 3.36%.

The input route is not optional, and --batch-size is not a substitute

Ruled out 2026-09-15. CLAUDE.md documents raising --batch-size as the fix for time-paced content that looks stalled (Diablo's Blizzard North logo is byte-identical for 23,000 batches and plays straight through at --batch-size=200000), so it is the natural first thing to reach for here. It does not work on StarCraft, because this is not the same problem: Diablo is pacing itself off the clock, while StarCraft is waiting for input it never receives. Two no-input runs against the same prebuilt artifact:

run batches 0x004411db 0x004411e7 0x004cbaf0
default batch size, --max-seconds=40 24550 0 0 1
--batch-size=200000, --max-seconds=60 534 0 0 1

Zero simulation frames either way, and exactly one display flush. Only the escape/click route above gets past it.

Cheap gate: --count=0x004411db reading 0 means you are not in gameplay, whatever else the run looks like. That check is worth making before believing any StarCraft profile, because every generic health metric reports a thoroughly busy machine at the same time — the 40s run above retired 536,403,711 handler ops at 614 batches/s with a dense handler histogram and a plausible-looking hot loop. Nothing in those numbers hints that the game has not started.

What a startup profile actually measures

The same 40s no-input run, --handler-hist --handler-hist-thread=0:

share
H422 $th_mmx_rr 6.95% 45,916,262 MMX instructions retired
H395 $th_smack_huff_walk 1.45% bounded Smacker Huffman node walk

and every one of the top 20 hot blocks lands in 0x008e4xxx/0x008e8xxx, which is inside smackw32.dll (loaded at 0x8d6000, origBase=0x10000000). So "mostly Smacker" is literal: a startup census is a census of the intro video decoder, and optimising anything it names would speed up the movie and nothing else.

Gameplay-window counts

Matched --no-build runs to batch 4300 and 4700, using the input route above, give this post-OK delta:

Counter 4300 4700 Delta Interpretation
dx_present 5908 7622 +1714 DirectDraw primary display work; too frequent to be a logical frame.
0x004767e0 118 11132 +11014 Hot gameplay routine, not frame-rate cadence.
0x004b469b 1355 187918 +186563 Very hot helper.
0x004142d0 3510 55002 +51492 Very hot helper.
0x004869ae 19 20136 +20117 Hot gameplay/render helper.
0x00403930 1230 2551 +1321 Map/tile update path; still too hot.
0x00461fd8 1229 2537 +1308 Scans the dirty 16x16 tile byte map at 0x694648; display invalidation, not simulation.
0x004c74b0 1071 1147 +76 Low-ish cadence, but static/caller trace shows rectangle/display work.

The 0x004c74b0 trace after batch 4300 reports:

EIP=0x004c74b0 prev_eip=0x00463410 bp_first_caller=0x00463410
[esp+0]=0x00463435 [esp+4]=0x00631e58 [esp+8]=0x4e71d656 [esp+12]=0x4e71d636

Static disassembly confirms 0x00463410 calls 0x004c74b0, then draws/copies small rects via 0x004c4de0 and 0x004c4d90. 0x004c74b0 itself prepares a rect and calls 0x004c6280. So the only low-delta candidate found so far is still on the display/dirty-rect side.

Why there are many unlock presents

The post-gameplay Storm unlock trace answers the DirectDraw side directly. A trace at storm+0x1500d4f9 after batch 4300:

timeout -s KILL 210 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --no-build --batch-size=100000 --max-batches=4550 \
  --no-close --repaint-every=200 \
  --input=100:focus-main-window,120:keydown:27,125:keyup:27,300:keydown:27,305:keyup:27,600:keydown:27,605:keyup:27,900:keydown:13,905:keyup:13,1200:keydown:27,1205:keyup:27,3600:mousemove:545:393,3610:mousedown:545:393,3620:mouseup:545:393,4250:mousemove:200:263,4260:mousedown:200:263,4270:mouseup:200:263 \
  --trace-at=storm+0x1500d4f9 --trace-at-start-batch=4300 \
  --trace-at-limit=80 --frame-stats \
  > /private/tmp/starcraft-trace-storm-unlock-4550.log

The first 80 post-gameplay Storm unlock helper calls grouped by the StarCraft return address in [esp+8]:

Return after Storm unlock Count Meaning
0x004c418e 76 Return from 0x004c4140, the full-surface commit helper.
0x004c4261 4 Return from 0x004c4220, a smaller locked-surface/rect helper used by 0x004d00b0.

The dominant chain is:

0x004c47c4 -> 0x004c4800 -> 0x004c4140 -> Storm ordinal #356 -> IDirectDrawSurface::Unlock -> $dx_present

0x004c4800 first calls Storm ordinal #351 through 0x004b5eb0, calls 0x004d1094 with the dirty-tile map at 0x694648, performs the full-surface commit through 0x004c4140, then clears 300 dwords at 0x694648. The full commit helper 0x004c4140 locks the primary surface, copies from the StarCraft screen buffer at [0x694644] using width 0x280, unlocks it, and returns.

So yes: StarCraft can produce many DirectDraw unlock-driven presents for what a player would perceive as one coarse gameplay moment. They are repeated dirty/full-screen commit passes, not browser compositor frames and not proven simulation ticks.

The clock-side probe did not find a cleaner logical frame yet. Matched 4300/4700 counts for clock consumers included:

Counter 4300 4700 Delta Interpretation
0x00439d80 2036 3485 +1449 100ms GetTickCount-gated animation/coordinate update helper; too hot as a global frame.
0x004cc000 10687 12136 +1449 Generic timer-list dispatcher at 0x696fc0/0x696fc4; scheduled callback processing, not one frame.
0x004c8430 3971 4363 +392 Timeout/slot expiry helper over 0x695f4c..0x695f6c; not the root frame.

0x00439d80 adds 0x64 to its next deadline and updates globals around 0x6945a2..0x6945a8; 0x004cc000 dispatches timer-list entries based on GetTickCount; 0x004c8430 checks a small timeout table. These explain some clock-driven display/UI activity, but they do not establish the main RTS simulation frame boundary.

Cross-commit frame comparison

To check whether a commit changes StarCraft frames, compare clean worktrees, not the shared dirty checkout:

bash tools/make-test-worktree.sh /private/tmp/sc-frame-old <old-sha>
bash tools/make-test-worktree.sh /private/tmp/sc-frame-new <new-sha>

Run the same deterministic gameplay route in each worktree and capture fixed batches:

timeout -s KILL 300 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --batch-size=100000 --max-batches=4700 --no-close \
  --repaint-every=50 \
  --input=100:focus-main-window,120:keydown:27,125:keyup:27,300:keydown:27,305:keyup:27,600:keydown:27,605:keyup:27,900:keydown:13,905:keyup:13,1200:keydown:27,1205:keyup:27,3600:mousemove:545:393,3610:mousedown:545:393,3620:mouseup:545:393,4250:mousemove:200:263,4260:mousedown:200:263,4270:mouseup:200:263,4300:png:/tmp/sc/f4300.png,4320:png:/tmp/sc/f4320.png,4350:png:/tmp/sc/f4350.png,4400:png:/tmp/sc/f4400.png,4550:png:/tmp/sc/f4550.png,4699:png:/tmp/sc/f4699.png \
  --frame-stats > /tmp/sc/run.log

Then diff matching captures:

node tools/png-diff.js old/f4300.png new/f4300.png --out=diff-f4300.png

Sample comparison, 34b4f08f (Fix Win98 installer chain launches) to c6a262e0 (Model creatable DirectDraw surface enumeration):

Batch Differing pixels Share Changed box Notes
4300 390 0.1270% 25,6 515x258 Tip text/cursor/resource animation region.
4320 25 0.0081% 201,265 17x17 Tiny cursor/tip-region delta.
4350 35 0.0114% 201,267 18x16 Tiny cursor/tip-region delta.
4400 460 0.1497% 24,241 195x42 Tip text/cursor-region delta.
4550 13 0.0042% 201,265 11x11 Tiny cursor-region delta.
4699 395 0.1286% 471,162 27x29 Small animated unit/building-region delta.

The same run reported nearly identical present counts: 7618 on 34b4f08f versus 7614 on c6a262e0. This means the method can detect frame pixel changes between commits, but fixed-batch StarCraft captures include legitimate animation/timer phase noise. Treat small localized diffs as "changed frame phase/content" until a second oracle, such as a paused-game capture or a masked-cursor/UI region diff, proves they are rendering regressions.

How to find the game frame

Do not start by counting DirectDraw. StarCraft has at least three loops active during startup, and two of them are proven false positives:

The next useful probe is therefore a gated one:

  1. Drive to the in-mission screen (ophelia terran1) and only begin counting after the visible canvas hash matches gameplay, not a .smk cinematic.
  2. Count Smacker exports at the same time. If _SmackDoFrame or _SmackNextFrame is still moving, the sample is still movie playback and cannot identify the gameplay frame.
  3. Count candidate EXE routines around the renderer/update fan-in, and accept a candidate only if it correlates with visible canvas changes and stops being the outer message pump cadence.

Useful command fragments:

# False-positive filter: movie frames versus outer loop.
timeout -s KILL 45 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --max-batches=900 --batch-size=100000 \
  --count=smackw32+0x10005c70,smackw32+0x10006440,smackw32+0x100064e0,smackw32+0x10001f60,exe+0x4c46b0,exe+0x4c4800,exe+0x474d2b \
  --frame-stats > /private/tmp/starcraft-smack-count.log

# DirectDraw caller mapping, useful after gameplay starts but not sufficient by
# itself.
timeout -s KILL 45 node test/run.js --app=starcraft_shareware --threads \
  --quiet-api --max-batches=900 --batch-size=100000 \
  --trace-api=IDirectDrawSurface_Unlock --trace-stack=12 --trace-callstack=12 \
  --frame-stats > /private/tmp/starcraft-unlock-stack.log

The 900-batch Smacker-filter result was:

guest present (dx_present): 719
smackw32+0x10005c70 (_SmackDoFrame@4): 1309
smackw32+0x10006440 (_SmackNextFrame@4): 1308
smackw32+0x100064e0 (_SmackToScreen@28): 0
smackw32+0x10001f60 (_SmackBufferBlit@32): 0
exe+0x004c46b0: 1
exe+0x004c4800: 1
exe+0x00474d2b: 1473

Interpretation: this sample is not gameplay. It is Smacker playback plus the outer pump. Any "frame" boundary found from this window would be a movie frame or a scheduler/message-loop tick.

For browser-visible validation, use tools/profile-web-frames.js because it already samples whole-canvas thumbnail hashes once per second and reports distinct hashes. Add an after-launch hook to count guest-side events only after launch:

node tools/profile-web-frames.js --app=starcraft_shareware --query='?debug&perf' \
  --warmup=60 --seconds=10 --screenshot=/private/tmp/starcraft-profile.png \
  --after-launch='window.__scFrames=[]; const old=wine.onGuestFrame; wine.onGuestFrame=e=>{ window.__scFrames.push({t:performance.now(),kind:e&&e.kind}); if(old) old(e); }; "ok"' \
  --report-eval='JSON.stringify({events:window.__scFrames.length, recent:window.__scFrames.slice(-10)})'

A real gameplay-frame candidate should satisfy all of these:

The latest gameplay probe narrows the known-good boundary to display work: 0x004cbaf0 is the central dirty-rect flush/update routine, 0x004cbc13 calls 0x004c46b0, 0x004c4800 commits toward the Storm Lock/Unlock path, and [0x696fac] behaves like a display flush/dirty-rect sequence. This is useful for DirectDraw backpressure accounting, but it is still not the simulation tick.

Timer dispatcher and frame-boundary candidate (2026-09-01)

The best current disassembly lead for StarCraft's own update cadence is not a DirectDraw call. It is the EXE timer-list dispatcher:

0x0043bd2a -> 0x004cc000
0x004cc000 reads GetTickCount, walks list head 0x696fc0, and dispatches expired
timer nodes.
0x004cc049 calls the per-node callback stored at [esi+0x8].
0x004cc05f calls the default helper 0x004cc0e0 when [esi+0x8] is zero.

The node fields observed in disassembly are:

[node+0x04] callback object/context passed in ecx
[node+0x08] callback eip, called at 0x004cc049
[node+0x0c] last GetTickCount timestamp
[node+0x10] interval in ms
[node+0x14] timer id, passed in dx
[node+0x18] drift/smoothing accumulator

A focused trace of 0x004cc049 while driving toward the mission saw the active callback set change by phase:

Phase Callback Interval Notes
Early/movie/UI 0x004ab130 0x1f4 (500 ms) Too slow; not a gameplay frame.
Mission-load/briefing UI 0x004cdde0 0x1e (30 ms) UI/object timer family.
Mission-load transition 0x00451080 0x14 (20 ms) Counts down [0x623950]; not a frame.
Mission-load transition 0x00458b80 0x64 (100 ms) Calls 0x004c9dc0/0x004cbed0; not enough evidence for frame.
In-mission/tips window 0x0049cc90 0x32 (50 ms) Best current app-level update candidate.
In-mission/tips window 0x00464b10 0xc8 (200 ms) Watches globals 0x4edfc8/0x4edfca, marks a UI/display object dirty through 0x004c9d80; not a frame.

The static registration site around 0x00464113 installs 0x00464b10 as a 200 ms callback and 0x00464900 as another 200 ms callback, so those are confirmed scheduled timers, not guessed callsites. 0x00464b10 only compares two globals with 0x631e60/0x631e64 and jumps to the object dirty/display helper 0x004c9d80 when they changed.

0x0049cc90 looked like the closest thing to an actual gameplay/update tick in the first timer-dispatch pass, but later disassembly weakens that interpretation. It is registered as a callback in the 0x0049ca..0x0049d0 region and branches on the timer id in dx; one branch calls 0x004cbed0 to remove or reschedule timer entries, and other branches update state rooted around 0x66c270..0x66c3dc. The branch bodies set object fields +0x24/+0x26, call 0x0049c640, touch screen-coordinate globals 0x4eeb2c/0x4eeb30, and end by dirtying the object through 0x004c9d80. That makes it a timed UI/selection/object callback, not a proven RTS simulation frame.

Matched 4300/4700 counter runs are partially useful but not definitive. The 4300 baseline reached the post-OK click point:

Counter 4300 Notes
dx_present 5910 DirectDraw display work, still too hot.
0x0049cc90 1062 Timer callback candidate.
0x00464b10 405 200 ms UI/display watcher.
0x004cc049 8232 Generic timer callback dispatch, too hot.
0x004c4800 / 0x004cbaf0 7981 / 7981 Display flush path.
Smacker _SmackDoFrame / _SmackNextFrame 3570 / 3533 Startup/transition movie work still contributes before gameplay.

A later 4700 count run hit a StarCraft critical-error modal before the end, so its deltas are tainted and should not be used as proof. It still showed 0x0049cc90 rising to 1848 and Smacker almost flat at 3573/3536, which is consistent with the callback remaining active after the movie phase, but not a clean oracle.

Cleaner 4550/4700 reruns with a narrower counter set did reach the in-mission state without the critical-error modal:

Counter 4550 4700 Delta Notes
dx_present 6949 7624 +675 DirectDraw display work, not one frame.
0x0049cc90 1725 2150 +425 Timed object callback; count alone does not prove a sim frame.
0x004cc049 9835 10868 +1033 Generic timer callback edge; too hot.
0x004c4800 / 0x004cbaf0 8857 / 8857 9434 / 9434 +577 / +577 Display flush path.
Smacker _SmackDoFrame / _SmackNextFrame 3570 / 3533 3573 / 3536 +3 / +3 Movie path mostly flat in this window.

Two pause-oracle attempts are not usable as proof. Injecting F10 at batch 4550 made a byte-identical batch-4690 screenshot and nearly identical counters. (Corrected 2026-09-19: F10 itself works — it opens the Game Menu, confirmed by screenshot. What failed here was F10 on the escape route, which at 4550 is not yet in the state that accepts it. On the click route below, F10 at batch 1750 opens the menu immediately. Read this row as "the escape route was not where we thought it was", not as "F10 is not delivered".) Injecting VK_PAUSE at batch 4550 changed only 335 pixels in an 18x32 box against the unpaused capture, while the timer/display counters stayed within normal run-to-run noise (0x0049cc90 2149 vs 2150, 0x004cc049 10865 vs 10868, display flushes 9427 vs 9434). This does not show the game simulation paused.

20 ms Win32 timer and clock catch-up path (2026-09-01)

A stack trace of GetTickCount did find a stronger timing path than 0x0049cc90. The partial run was killed at the hard timeout, but it still named the dominant return sites:

API return site Partial count Notes
GetTickCount -> 0x004652c8 158220 Inner clock-poll loop in 0x00465260.
GetTickCount -> 0x004cc00f 9130 Generic timer-list dispatcher.
GetTickCount -> 0x0043bcaa 9130 Main message/timer pump before 0x004cc000.
GetTickCount -> 0x004c8438 3448 Timeout/slot expiry helper already noted above.
GetTickCount -> 0x0046ca76 1151 250 ms network/player-message pump in 0x0046ca60; rejected as a frame boundary.

The 0x004650d0..0x00465346 family is a Win32 timer/catch-up driver:

0x004650f8 calls SetTimer(hwnd=[0x62fb04], id=3, interval=0x14, proc=0x00465140)
0x00465140 is the timer proc; when active it advances [0x631e98] by 0x190.
0x00465260 is a synchronous drain/catch-up helper.
0x004652b7 loads KERNEL32!GetTickCount from IAT 0x004d517c.
0x004652c6..0x004652cc polls GetTickCount until the delta is non-negative.
0x004652d7..0x004652e5 subtracts 5 from [0x631e98].
0x004652e7 calls Storm ordinal #261 through thunk 0x004b5f8e.

The Storm import map makes 0x004b5f8e slot 39 of the EXE's storm.dll IAT, ordinal #261 (0x004d53d8). The catch-up helper passes [0x631e8c], the accumulator value, and zero to that thunk.

A focused count to batch 4700 showed why this still is not a single frame boundary:

Counter Count Interpretation
0x00465140 6 20 ms Win32 timer proc; only a few firings in this route/window.
0x00465260 3 Synchronous catch-up/drain helper.
0x004652c6 451316 Tight GetTickCount polling loop; definitely not a frame.
0x004652e7 64 Emission of catch-up events through Storm #261.
0x004b5f8e 73 Total calls through Storm #261 in the counted run.
0x0049cc90 2147 Timed object callback still active, but not the best frame lead.
0x004c4800 / 0x004cbaf0 9435 / 9435 Display flush path.

Static Storm disassembly resolves and rejects the #261 leg as a logical frame boundary. The bundled storm.dll export table maps ordinal #261 to 0x15011b80 in the original image (storm.dll sha256 d28093f889f2d9fe1475ee55b47fbfab4d8241fdf73af105d0b85c1514875290). That function enters critical section 0x1502d308, stores/validates the first argument through 0x1500fc90, looks up a node in list head 0x1502d330 by node+0x8 == arg0, then updates node+0x28 and node+0x2c from args 2/3. When those fields change it invokes methods on node+0x30 at vtable offsets 0x3c and 0x40.

The surrounding exports identify this subsystem as Storm sound/WAVE streaming, not gameplay simulation. #254 (0x1500fca0) is a wrapper into 0x1500fcd0; the create path requires global 0x1502d374, opens the resource, checks RIFF and WAVE, seeks fmt and data chunks, allocates backing buffers, and initializes DirectSound worker state through 0x150100f0. Adjacent exports #257/#258/#259 unregister/query the same list nodes and call other vtable offsets on the same sound object. So 0x004652e7/Storm #261 should be counted as sound/clock catch-up parameter updates, not a game frame.

This path still explains why StarCraft can generate many display flushes per real app cadence, and why adding backpressure at the DirectDraw present/unlock boundary changes clock-paced animation. It does not prove that any single one of 0x00465140, 0x00465260, 0x004652e7, or Storm #261 is the logical RTS frame boundary; #261 is now specifically rejected for that purpose.

The remaining GetTickCount -> 0x0046ca76 consumer is also rejected. Static disassembly puts it in function 0x0046ca60, with a 250 ms gate against [0x632520]:

0046ca74 call GetTickCount
0046ca76 mov edx, [0x632520]
0046ca7e sub eax, edx
0046ca84 cmp eax, 0xfa

The taken path walks eight per-player/network slots under globals 0x632190, 0x6322b0, and 0x6322f0, calls Storm ordinal #122 through 0x004b5fb2 from helper 0x0046a7b0, dispatches messages through 0x0046bf90, and eventually refreshes [0x632520] at 0x0046ccfc. Its caller 0x00458560 maps the result to coarse UI/status state [0x695ed8] = 0x64/0x65 and then calls 0x004c91a0. A clean in-mission count to batch 4550 hit 0x0046ca60 only 1138 times while 0x004c4800 and 0x004cbaf0 hit 8868 times and dx_present hit 6956 times, which is the wrong cadence for a per-frame boundary.

The main pump is similarly only an outer scheduler boundary. Function 0x0043bb75 loops PeekMessageA/GetMessageA/TranslateMessage/ DispatchMessageA, then runs 100 ms and 1000 ms housekeeping from the GetTickCount -> 0x0043bcaa site:

0043bca4 call GetTickCount
0043bcaa mov ecx, [0x61ef54]
0043bcb5 cmp edx, 0x64
...
0043bd00 cmp eax, 0x3e8
0043bd0d call 0x492f80
0043bd12 call 0x464ea0
0043bd2a call 0x4cc000

0x004cc000 is the timer-list dispatcher, not a frame callback. Its nodes are 0x1c bytes: [+4] owner/context, [+8] optional function callback, [+0c] last GetTickCount, [+10] period, [+14] tag, and [+18] smoothing/carry. At 0x004cc049 it calls [node+8] when present; otherwise 0x004cc0e0 invokes the owner object at vtable offset +0x2a. 0x004cbed0 removes nodes by owner/tag, and direct refs show dozens of UI/game objects using this list, so counting the dispatcher is counting scheduled callbacks, not game frames.

Gameplay-only hot-block profiling found the game-step edge above the hottest rendering loops. A clean worktree run with --handler-hist-thread=0 --handler-hist-start=4550 --handler-hist-stop=4700 --hot-block-dump=/private/tmp/sc-hot-4550-4700.txt recorded 7,539 distinct main-thread blocks in that in-mission window. The top blocks were decompression/blit/display work, not a low-frequency simulation tick:

Block Hits Classification
storm.dll runtime 0x007c108b..0x007c10ce 1.39M-1.78M each Storm/DLL inner loops, too hot and not EXE frame state.
0x004b48aa 1,239,329 Byte decode/copy loop through table 0x4e8701.
0x004c4670 / 0x004c4678 611,974 / 611,787 Display copy/scan loop reached from the 0x004c4800 presenter path.
0x0046201f / 0x00461fe4 511,494 / 499,458 640x400-ish tile/pixel scan loop.
0x00441d56 / 0x00441db6 498,644 / 510,680 16x16 row/terrain blit helper; calls 0x461fc0, 0x4618d0, 0x4b2640, 0x412760 after the scan.
0x0046263a 457,758 Tile lookup loop from 0x630770 + index*4.

This profile is useful negative evidence: once StarCraft is in mission, the hottest blocks are rendering/decode loops. The logical step boundary is the caller above them:

0x004410e1  call 0x0043bb50        ; message/timer pump
0x004410eb  call ebp               ; current tick/time source
...
0x00441144  sub esi, [0x61fcc8]    ; elapsed >= accumulator?
0x00441152  call 0x00441330        ; gate for another catch-up step
...
0x004411db  call 0x004b2ed0        ; game-step/update service
0x004411ed  mov edx, [0x61fcc8]
0x004411f5  mov al, [ecx+0x4d563c] ; step quantum from current mode data
0x004411fb  add edx, eax
0x004411fd  mov [0x61fcc8], edx    ; advance simulated time accumulator

The in-mission hot-block window counted 0x004410e1 570 times, but the actual step call at 0x004411db -> 0x004b2ed0 only 519 times. That matches the control flow: the outer loop may pump messages/display without consuming a sim step, while the inner path consumes one accumulated game step. In the same window, 0x004b2f79 and 0x004b3197 also hit 519 times, confirming they are inside the step service rather than independent frame clocks.

Matched-route counter runs strengthen that from a profiling observation into a boundary: the function entry and the accumulator-after-call sites move together within each run, while display presents and generic timer callbacks do not. The 4550 and 4700 runs are separate launches, so read the delta as a route-window comparison rather than same-process subtraction.

Counter 4550 4700 Delta Interpretation
dx_present 6956 7623 +667 Primary-surface present events, not logical frames.
0x004410e1 2458 3024 +566 Outer catch-up/message loop; can spin without a game step.
0x00441150 2025 2542 +517 Step-gate path before the game-step call.
0x004411e7 / 0x00441205 2025 / 2025 2542 / 2542 +517 / +517 Post-call accumulator path.
0x004b2ed0 2025 2542 +517 Game-step/update service entered from 0x004411db.
0x004b3197 2025 2542 +517 Same service, same cadence as the step call.
0x004b2fc7 54 87 +33 Derived elapsed-game timer, about every 16 in-mission steps.
0x004c4800 / 0x004cbaf0 8868 / 8868 9432 / 9432 +564 / +564 Display flush path; more frequent than game steps.
0x004cc049 9844 10860 +1016 Generic timer callback dispatcher, far hotter than game steps.
0x0049cc90 1722 2146 +424 Timed UI/object callback; not tied to the accumulator path.
0x0046ca60 1138 1138 0 250 ms network/player-message pump; inactive in this window.

0x004b2ed0 itself walks per-player/unit turn state and then maintains coarser countdowns. Its 0x004b2fc7 -> 0x004b2fce path increments global 0x4fc4f0, but only when the 0x68f7a0 countdown reaches zero; in the gameplay-only window 0x004b2fc7 hit 33 times, i.e. about once every 16 game steps. 0x4fc4f0 is therefore a derived elapsed-game timer, not the frame boundary itself. Its xrefs compare it to timer-like constants such as 0x3c, 0x258, 0x5dc, 0xa8c, and 0x1194, and 0x004b2fce is its only steady increment while 0x004b3853 resets it.

Current answer: the actual EXE-side logical step boundary to count is 0x004411db -> 0x004b2ed0, with 0x004410e1 as the enclosing catch-up loop. The display boundary remains 0x004cbaf0 -> 0x004c4800 -> Storm #356 Unlock -> $dx_present; count that for "surface flushes/presents", not simulation frames. The strongest generic timer boundary remains 0x004cc049; count that for "scheduled callback dispatches". The 0x004650d0..0x00465346 Win32 timer/catch-up path is real clock work, but its Storm #261 call is sound-state work, not the game frame. The remaining known GetTickCount consumer at 0x0046ca76 is a 250 ms network/player-message path, also not the game frame.

Browser measurement hint: lib/apps.js now declares starcraft_shareware.perf.logicalFrame with primary counter 0x004b2ed0 and verifier 0x004411e7. The perf HUD should show that as GAME/s and keep the generic DirectDraw path as PRESENT/s. The verifier is not a second FPS number; it is the post-call accumulator path that should stay 1:1 with the primary counter.

Corrected 2026-09-25: those two addresses are from the wrong exe. lib/apps.js mounts starcraft-demo-official/installed/starcraft.exe, and in that file 0x004b2ed0 and 0x004411e7 are mid-instruction, so the browser HUD read GAME 0.0/s in live gameplay (checked on ascii.dev: both counters armed, EIP in the exe, counts 0). The same call site in the mounted exe is 0x10 later: 0x004411eb call 0x004b29f0, verifier landing 0x004411f7. lib/apps.js now uses those. Any number above taken with --app= is from the demo-official exe; one taken with --exe=.../starcraft-shareware/... is from the other.

DirectDraw Lock/Unlock coordinate answer for this build: Lock(this, lpDestRect, lpDDSD, dwFlags, hEvent) can carry a rectangle. The WAT handler uses lpDestRect.left/top to return an lpSurface pointer offset into the same surface DIB while keeping the full pitch. Unlock(this, lpRect) has a second argument in the API table and handler, but the current implementation does not read it; every unlock notes CPU write for the whole surface object and presents if that surface has the primary flag. The current host_dx_trace Lock/Unlock records only kind, slot, flags, DIB pointer, and zero for the last field, so updated-region bounding boxes are not available from the existing Lock/Unlock trace.

Candidate boundaries

Next probes

0x004b4880 is the GRP blit nest, it is 30% of gameplay, and it is NOT folded

Measured 2026-09-15 in a frozen --control session at supply 15-16/42, main thread, --no-threads. One 100-batch window: 23,252,268 block entries, 115,770,179 ops (5.0 ops/block), of which the 0x4b48xx nest is 30.0%. It is the single hottest thing in the game, well ahead of Storm's decompressor.

The shape is an RLE sprite blitter with a palette LUT at 0x4e8701. A token byte at [ebp] selects one of three arms from the head at 0x4b4892:

arm token what it does
0x4b48e3 dl & 0x80 transparent skip -- add edi, edx, touches no memory
0x4b48d7 dl & 0x40 fill run -- one LUT'd byte stored edx times (self-loop)
0x4b48aa else literal run -- per byte [ebp] -> LUT -> [edi-1] (self-loop)

Both inner loops are self-loops, so $loop_match_block does see them, and tools/match-loops.js accepts both statically (0x4b48aa LUT_RUN size=1 stride=1, 0x4b48d7 FILL_RUN size=1 stride=1). The runtime matcher declines both. --loopmatch-stats for the main instance over a run that reaches gameplay: self-loop blocks decoded 10426, matched 4, bounded LUT matches 0.

Do not mistake the hit counts for folded runs. In the same window 0x4b48aa was entered 1,797,797 times against 382,721 entries of its exit block 0x4b48bb -- 4.7 entries per run, i.e. one block transfer per pixel. 0x4b48d7 is 16x per run. A folded loop would enter once per run.

Why 0x4b48aa declines, exactly. src/07b-loop-match.wat:1910 (and the bounded twin at :2237) is a hard gate, zero_cnt != 1 -> decline, and the comment at :1848 states the rule: the LUT accumulator must have been zeroed in-block, because a byte load writes only the low 8 bits and the table read has to be provably bounded to 0..255. StarCraft's xor eax, eax is at 0x4b48a7 -- in the predecessor block, not the loop body. Every LUT loop that does match (Heroes II 0x004c755d, StarCraft's own 0x4848e0 / 0x49e073) carries its xor inside the body. That one condition is the whole difference.

0x4b48d7 declines for a different reason: the runtime matcher has no generic FILL_RUN family at all. Its fill lowerings are the exact AoE ones ($loop_aoe_fill); FILL_RUN exists only in tools/match-loops.js, which models the design doc rather than the shipped WAT. Do not read a static match as a claim about the runtime.

Ops emitted for the two bodies, from --trace-loopmatch + loopmatch-decode.js:

block 0x4b48aa  7 ops        block 0x4b48d7  4 ops
   28 th_load8_ro  op=0x5       29 th_store8_ro op=0x7
   64 th_inc_r     op=0x5       64 th_inc_r     op=0x7
   28 th_load8_ro  op=0x30      65 th_dec_r     op=0x2
   64 th_inc_r     op=0x7      312 th_jcc_nz    op=0x0
   65 th_dec_r     op=0x2
   29 th_store8_ro op=0x37
  312 th_jcc_nz    op=0x0

Every op carries a classified role, so role classification is not the blocker.

Cost per game frame is flat, and it is not view-dependent

Eleven 150-batch windows (30s game time each) in one frozen session, counting block entries at 0x004411e7 as frames:

241040  244968  227524  249220  256626  234888  244560  246776  254041
busy base view 240838      fully fogged view 212016

Spread is +-8% around ~240k. Moving the camera to fully-fogged black terrain -- no sprites drawn at all -- lowered it only 13.6%, so ~85% of a game frame's cost is view-independent. The 0x4b48aa literal-run block does drop out of first place under fog (0x4b48d7 leads there instead), so it is genuinely the sprite work; it just is not what sets the floor.

CORRECTION to the "82,818 blocks per game frame" baseline recorded above: that figure is a whole-run average (38,096,358 blocks / 460 frames) that includes boot, menus and the intro, where frames are cheap. It is not comparable to an in-gameplay window, and the "3.6x growth as units are built" reading taken from comparing the two does not survive. Against gameplay-only windows, per-frame cost has been flat at ~240k throughout, from supply 12/42 to 16/42.

Where the hot loop did shift is between scenes, not with unit count: in an earlier gameplay window Storm cluster A (7c108b/97/ad/ce) led at 15.68% and 0x4b48aa was not on top; in these windows cluster A is 5-8% and the 0x4b48xx nest is 30%. Per tools/hot-loop-census.js's rule, read the spread across windows, not one window.

Frozen interactive mission smoke on 0fd9aeb7 (2026-09-16)

A clean checkout built both WASM artifacts and ran the shipped entry with:

node test/run.js --app=starcraft_shareware --no-build --no-threads --quiet-api --no-close --repaint-every=50 --max-seconds=3600 --batch-size=100000 --stuck-after=100000000 --control=8125 --frozen

node tools/ctl.js -s :8125 drove and photographed each stage. At batch 120 a click at (320,240) skipped the opening material; batch 670 showed the mission briefing, and a click at (545,393) started it. Batch 870 showed the Tips dialog; a click at (198,261) dismissed it. Batch 970 showed unobstructed gameplay. Selecting four marines required a drag from (115,100) to (190,185), with five frozen steps between mouse down, move, and up. A right click at (360,190) moved them visibly by batch 1110. Later movement produced combat and one dead marine: supply fell from 12/42 to 11/42 and the selected unit count fell from four to three at batch 1440. Gameplay still rendered at batch 1740, without a runtime trap or error modal.

After exports.reset_handler_hist(); exports.set_handler_hist_enabled(1), the hot-block histogram counted visits to the actual verifier at 0x004411e7:

Batch Verifier hits Block entries Blocks per additional verifier hit
1110 288 38,901,221 —
1440 1,148 136,027,670 112,938
1740 2,043 227,043,629 101,694

The game kept updating through the battle, and this bounded sample shows no increase in block cost per update. Node heapUsed was 23.3, 22.5, and 23.6 MB at those same points; WASM memory stayed at its fixed 512 MiB. rss fluctuated 94.7, 56.0, and 101.9 MB, so it does not support a monotonic leak claim. The main thread's ESP was 0x074ffd94 at batch 1440 and 0x074ffe68 at batch 1740; those two snapshots do not diagnose stack drift across all guest calls. This smoke is shorter and less busy than the late large battle that originally crashed, so it is evidence of mission and combat functionality, not proof that the old crash cannot recur.

Gameplay CPU profile on a quiet box (2026-09-18) and the VirtualAlloc fix

The first quotable profile of in-mission gameplay: node --cpu-prof on the ascii.dev bench box (x86_64 V8, load 1.00) at batch 1750 and again at 4250, subtracted with tools/cpuprof-diff.js so boot cancels. Window 97.8 s CPU, 97.5% wasm:

share function
40.1% $virtual_backing_conflicts
9.3% $next
6.3% $read_thread_word
4.2% $get_reg
2.4% $set_reg
2.3% $g2w
2.1% $branch_end_at

Storm's allocator does VirtualAlloc(0, 64K, MEM_RESERVE) + VirtualAlloc(p, 4K, MEM_COMMIT) per block and frees as often: 27,530 VirtualAlloc and 18,675 VirtualFree calls by batch 4250, 2,728 mappings live at the end. In $virtual_map_commit_locked the coalesce test AND-ed the full-table conflict scan with two cheap equalities, and WAT i32.and evaluates every operand, so the scan ran once per record of the outer loop: records squared, 2.8 ms per commit. Fixed in 1630c665 by nesting the scan under the equalities.

Two-point gameplay CPU on the same box, one build per arm, three reps:

arm rep 1 rep 2 rep 3
off (267dd879) 127.3 (outlier) 97.0 95.9
null (same build) 95.1 96.6 —
fix (1630c665) 58.1 58.5 59.3

−39% gameplay CPU, boot 78 → 73 s. For scale, the six rounds of dispatch levers measured on the same box the same day: chaining +1.4%, executor +6.1%, both +7.0% — all slower.

Who else pays: a 4000-batch census (--trace-api=VirtualAlloc --dump-vmap, batch size 20000) — Diablo II demo 648 calls / 717 live maps and climbing (affected), Diablo shareware in Tristram 27 / 14 (not), Warcraft III demo 231 / 97 (headless run is idle, unmeasured), Quake II 106 / 47, RCT, Heroes II, Caesar III ≤ 2 calls (not). The cost is calls × live-records², so only reserve-and-commit-per-block allocators with a large live set feel it.

The profile after the fix (2026-09-18)

Same two points, same box, arm C (1630c665). Window 61.8 s CPU, 96.1% wasm. Nothing is left that stands out; the top is the interpreter's own machinery:

share function
15.2% $next
10.5% $read_thread_word
6.7% $get_reg
4.4% $set_reg
4.2% $g2w
3.7% $branch_end_at
2.2% $gl32
1.9% $page_resolve
1.5% $th_test_jcc
1.4% $jcc_end
1.2% $decode_block

$virtual_backing_conflicts is gone from the top 30. Dispatch plus register and memory accessors ($next, $read_thread_word, $get_reg, $set_reg, $g2w) are 41% of the window, every one of them a leaf V8 refuses to inline (see tools/inline-verdicts.js). No single handler is above 1.1%. That is the shape the lever study already priced: fewer dispatches did not buy anything here, so the next win is per-dispatch cost, not dispatch count.

A census of the same i32.and (cheap) (call expensive) shape across all of src/ (tools/wat-eager-call-census.js --loops, f1c68a58) found two more sites worth fixing, neither in StarCraft's hot path: PeekMessageA scanned the timer table on every peek and, under PM_REMOVE, consumed a due timer a filter had excluded; the colour-keyed Blt walked the clipper list per pixel before testing the key.

Driving a real save, headless (2026-09-19)

The in-game menu is reachable and the game will write a save file with no browser and no hand-driven control session. This matters beyond StarCraft: the PKWARE-DCL implode fold (H466, --implode-cmp-run) only executes while a guest is compressing, so a save is the only route on which it can be measured at all — a launch sweep over the whole registry reports blocks 0 for all 201 apps and proves nothing about it.

Built on the click route (gameplay at ~1690), not the escape route:

node test/run.js --app=starcraft_shareware --no-build --no-threads \
  --quiet-api --no-close --repaint-every=50 --batch-size=100000 \
  --stuck-after=100000000 --max-batches=2400 --max-seconds=340 \
  --export-saves=/tmp/saves \
  --input=100:focus-main-window,120:keydown:27,125:keyup:27,\
670:mousemove:320:240,680:mousedown:320:240,690:mouseup:320:240,\
990:mousemove:320:240,1000:mousedown:320:240,1010:mouseup:320:240,\
1140:mousemove:545:393,1150:mousedown:545:393,1160:mouseup:545:393,\
1540:mousemove:198:261,1550:mousedown:198:261,1560:mouseup:198:261,\
1750:keydown:121,1760:keyup:121,\
1850:mousemove:316:82,1860:mousedown:316:82,1870:mouseup:316:82,\
1980:keydown:65,1982:keypress:65,1985:keyup:65,\
1990:keydown:66,1992:keypress:66,1995:keyup:66,\
2100:mousemove:200:261,2110:mousedown:200:261,2120:mouseup:200:261
Batch State
~1690 Unobstructed gameplay (250 min / 200 gas / 12/42 supply).
1750 F10 opens the Game Menu: Save Game / Load Game / Options / Help / Mission Objectives / End Mission / Return to Game (Esc).
1850-1870 Click Save Game at (316,82). Dialog: name field (focused, empty), Save / Delete / Cancel. Save is greyed until a name is typed.
1980-1995 Type the name. The field needs the keypress char event, not keydown alone — keydown moves focus/selection but does not enter a character.
2100-2120 Click Save at (200,261).

--trace-fs confirms the write rather than inferring it from pixels:

[fs] FindFirstFile("C:\save\Anonymous\*.sng") → FAIL
[fs] CreateDirectory("C:\save\") → created
[fs] CreateDirectory("C:\save\Anonymous\") → created
[fs] CreateFile("C:\save\Anonymous\AB.sng", creation=2) → 0x70000032
[fs] WriteFile(h=0x70000032, n=0x8000, wrote=0x8000, pos=0x0, size=0x8000)

Saves land under C:\save\<player>\<name>.sng, one directory deeper than the c:\save\* glob in lib/apps.js suggests. The player directory is Anonymous on a fresh profile.

The save is a compression workload, which is the point. On this route H466 matches 68 blocks and runs 6,255,490 times over 17,082,465 iterations — versus zero on every non-saving route measured.

Two traps. The registry persists c:\save\*, so a second run can start with the first run's save already present, which changes the dialog contents and the route; pass a fresh profile or a distinct name when that matters. And the 0x8000 write is a single fixed-size block — a save that "succeeded" in the trace has not necessarily finished, so let the run continue past the click before reading counters.

The save is a byte-exact A/B oracle, but only with --wall-clock-ms=N. Without it the same command line twice produces saves differing in 88,435 of 98,304 bytes, which reads convincingly as a nondeterministic emulator and is not: lib/host-imports.js's wall_clock falls back to Date.now() unless ctx.wallNowMs is bound, so GetSystemTime/GetLocalTime/ GetSystemTimeAsFileTime return the real calendar and the guest seeds itself from the time of day. Pin it and the route is reproducible to the byte:

# add to the command above
--wall-clock-ms=1789000000000

Measured 2026-09-19, three runs of the route (two off, one --implode-cmp-run): ab.sng sha256 41a21727… on all three, the whole exported VFS tree eb00de6d…, 1804 of 1804 log lines identical apart from the wall clock, and 2,235,672 API calls identical between the two off arms. H466 therefore passes the oracle — the implode fold is correct, not merely −2.77%. Note the DirectInput clock seam closed in 01b81f21 was not what made this route diverge; any new clock has to be closed on ctx, not on h.

The two off arms — byte-identical output, identical API-call count, so provably identical work — took 201.6s and 158.7s of wall clock, a 27% spread. (The fold arm's 94.0s is not comparable: it did less work.) Cross-run wall time on the shared box is not a measurement; use --slice-split for within-run phase shares and the load-immune counters for A/B.

2026-09-19 — five-window gameplay census: Storm's dirty-span present is 38% of block entries

Measured on the quiet box with --handler-hist --hist-json over five consecutive 500-batch windows of the harness route (--no-threads, --batch-size=100000, batches 1750..4250, PNG at each window end confirmed gameplay), then tools/hot-loop-census.js across all five. Counts are load-immune. Per window: ~145M block entries, ~725M ops, 5.0 ops/block, ~9k distinct blocks. The binary is the official demo exe (starcraft-demo-official/installed/starcraft.exe, what lib/apps.js mounts); starcraft-shareware/installed/starcraft.exe is a different build 1 KB larger whose code is shifted, and disassembling that one against these addresses reads as self-modifying code (it is not — dump-mem at runtime matches the official exe byte for byte). storm.dll is byte-identical in both.

region what mean share of block entries spread over 5 windows
storm+0x15024f12..150251bd body of Storm #437: builds per-line copy/skip span lists from the 40x30 dirty-tile mask 25.8% 1.7pp
storm+0x15024511..15024576 inner span loop of Storm #432: copies each span (aligned head, rep movsd, tail) 12.0% 0.7pp
exe+0x461e28..462236 per-tile map lookup loop ([0x6306d0+idx*4], 32-wide rows, writes stride 0x58) 5.7% 0.5pp
exe+0x40f31d..40f68e sprite/unit list walk 4.4% 0.2pp
exe+0x441d64..441de8 dirty-tile row loop, 40 wide, calls 0x4b3f9d per run of dirty tiles 3.6% 0.3pp
exe+0x4c4197..4c41ce dirty-tile byte scan, 40 wide (mov bl,[ebp]; inc ebp; test bl,bl) 3.0% 0.2pp

The first two are hot in every window at 25%+ and 11%+ and are the only regions above the census's 5% floor besides the map lookup. They are one mechanism: 0x4c4340-ish calls Storm #437 (thunk 0x4d0b84, call site 0x4c436b) with the dirty-tile map, then the full-surface commit helper calls #350 SDrawLockSurface, Storm #432 (thunk 0x4d0b78, call site 0x4c3cb9) with (surface, [0x694644] back buffer, 0x280, rect, span list), then #356 Unlock. #431 at 0x150243f0 is #432's unclipped twin. Span tokens are 16-bit: al = bytes to copy, ah = bytes to skip; a zero token ends the line.

Rates per window: #437's per-line head 0x15024f12 enters 910k times (≈1900 commits of 480 lines, ≈3.8 per batch — at 200 ms of guest time per batch that is ~19 commits per guest second, i.e. the game's own frame rate, not a runaway); its per-tile-cell state machine 0x1502508b/97/ad/ce enters 6.4M/5.5M/5.0M/4.7M times; #432's per-span head 0x15024511 enters 3.05M times (≈3.3 spans per line) and its token fetch 0x1502456a 3.96M. Neither function's entry block is in the top 400, so these are few calls doing long loops, which is the fold shape — and both loops are multi-block diamonds (find-loops.js/match-loops.js only see self-loop blocks, so they are invisible to every existing recognizer). Storm 1.06 is specific to the StarCraft demo/shareware builds: the #437 loop's bytes appear in neither Diablo Storm nor the Diablo II/Warcraft III Storms; the #432 copy tail does appear in Diablo II's and Warcraft III's Storm.

Per commit that is ≈76k block entries and ≈380k ops, of which the Storm span machinery is ≈29k entries. At the phone's ~18 fps the machine is retiring ≈7M ops/s, all of it interpreter, so a fold that took the two Storm loops to one dispatch per span / per tile cell is worth on the order of a third of gameplay CPU — the only lever of that size the census found. The 0x4b48xx GRP blit nest that was 30% on 2026-09-15 is not in this scene's top 20 at all (0x4b43xx blocks total under 2%): that measurement was one 100-batch window at supply 15-16, this is early gameplay — which is exactly why the census insists on several windows before naming a fold target.

Repro: $S/census.sh on the box (five run.js invocations with --handler-hist-start=A --handler-hist-stop=B --hist-json=census-A.json), then node tools/hot-loop-census.js census-*.json --top=20 --blocks.

Independently reproduced, different machine, different window, different harness: one 500-batch gameplay window (1750..2250, --hot-block-dump) on the shared box gives 140,680,127 block entries, of which the storm.dll span machinery (runtime 0x007bf5xx/0x007c00xx, the same code as the table above) is 35.8%. Two boxes agreeing to within 2pp on a load-immune counter is as solid as this measurement gets, and it is the largest single lever named for this app.

Negative: the colour-keyed blit at 0x004c7a68 is NOT a gameplay loop

Worth recording because it is the H455/SimGolf trap repeating almost exactly. Profiling batches 0..2090 makes 0x004c7a68 (a keyed byte blit, key 0, 8 instructions) look like the app's hottest loop at 10.8% of block entries, with 0x004c788f (a LUT blit) another 5.4% — 16.2% between them, and a plausible story attached (124,377 rows × ~198px, only 3.7M of 24.6M pixels opaque). It is also invisible to all three recognizers, which makes it look like a discovery.

In the pure gameplay window above those two blocks total 17,280 entries — 0.0123%, a factor of ~880 lower. The batches 0..2090 window is mostly boot, intro and menu; the blit is menu//intro compositing that stops almost entirely once the game is running. A fold built on that 10.8% would have moved gameplay by nothing, exactly as H455 did on SimGolf.

The rule this keeps re-teaching: a single profiling window that spans a phase change is not evidence about where an app spends its time, however large the share and however good the story. Run tools/hot-loop-census.js over several windows and build only against the "hot in EVERY window" list.

(The static shape itself is real and reasonably common — tools/find-ck-lut-nests.js finds 516 CK_COPY8_SRC matches across 224 of 1271 PEs, unlike RLE_RUN's 1 of 287. So the family may still be worth a fold on some app's evidence. It is just not StarCraft's.)

Present cap paced on the game step (2026-09-28)

The present cap now paces on perf.logicalFrame (0x004b29f0), not on presents or pumps. This is --present-at=logical, the default when an app declares a logical frame; --present-at=pump or ?present-at=pump is the A/B arm.

How it works: the decoder plants a marker op (handler 476, $th_logical_frame) at the head of the block entered at that address. The marker counts the step per thread. With a cap on, it also calls $present_pace and parks with EIP on the step.

The count used to come from --count / the browser's set_count. Those arm $dbg_any, which turned off block chaining and the micro-op tier for the whole run whenever the perf HUD (or --present-frames) was on. The marker has no such cost.

Route: box4, --no-threads, the click route from "Driving a real save" above.

node test/run.js --app=starcraft_shareware --no-build --no-threads --quiet-api --no-close \
  --repaint-every=50 --batch-size=100000 --stuck-after=100000000 --max-batches=4800 \
  --max-seconds=850 --present-frames=1800 [--present-cap=60 [--present-at=pump]] \
  --input=100:focus-main-window,120:keydown:27,125:keyup:27,670:mousemove:320:240,\
675:mousedown:320:240,685:mouseup:320:240,990:mousemove:320:240,995:mousedown:320:240,\
1005:mouseup:320:240,1140:mousemove:545:393,1145:mousedown:545:393,1155:mouseup:545:393,\
1540:mousemove:198:261,1545:mousedown:198:261,1555:mouseup:198:261,1700:tick-ms:5

1700:tick-ms:5 matters. On the default 200 ms batch, a pacer sleep ends the batch, so any cap reads as at most 5 steps per guest second.

arm GAME steps / guest-s frame ends / guest-s sleeps in window wall for 4800 batches
uncapped 21.8-22.0 96.9-98.4 - 82 s
cap 60, --present-at=pump 17.5-17.8 59.9-60.5 316-473 46-47 s
cap 60, --present-at=logical 21.7-22.3 97.8-99.1 0 82 s
cap 15, pump 17.8 21.1-21.4 302-307 30-31 s
cap 15, logical 17.5-17.9 24.9-25.1 263 (263 at the step) 31 s

What the numbers say: