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:
- Outer EXE pump:
0x00474d02..0x00474d32calls0x0043bb50and thenSleep(0). It is too hot; a 1400-batch count hit0x00474d2b2300 times. - Smacker movie loop: startup/mission transition videos call
smackw32!_SmackDoFrame@4(0x10005c70) andsmackw32!_SmackNextFrame@4(0x10006440). A 900-batch run hit them 1309 and 1308 times whiledx_presentfired 719 times. - DirectDraw present loop: Storm primary
Unlockemits$dx_present, but this is display work, not necessarily one game tick.
The next useful probe is therefore a gated one:
- Drive to the in-mission screen (
ophelia terran1) and only begin counting after the visible canvas hash matches gameplay, not a.smkcinematic. - Count Smacker exports at the same time. If
_SmackDoFrameor_SmackNextFrameis still moving, the sample is still movie playback and cannot identify the gameplay frame. - 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:
- Smacker counts are flat during the sample.
- The candidate count is near the visible changed-frame cadence, not the
Sleep(0)/message-pump cadence. - The candidate moves when gameplay is animated or scrolling and drops when the simulation is paused/stalled.
dx_presentmay be higher or lower than the candidate; that is allowed because it is the display-work boundary.
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
- Good DirectDraw boundary for display-work accounting:
storm.dllunlock helper entry0x1500d4f9, return0x1500d53c, exported via Storm ordinal#356; this is the dominant steady update point in the trace. - Good EXE-side display flush boundary:
0x004cbaf0, which reaches0x004c46b0from0x004cbc13, then the0x004c4800/Storm path. Count this for "guest display flushes", not for "game frames". - Dirty-tile/display helpers, not game frames:
0x00461fd8scans0x694648in 16x16 tiles, and0x004c74b0is called from0x00463410to prepare small rectangle drawing/copying. - Dominant unlock reason:
repeated
0x004c4800 -> 0x004c4140full-surface commits. In the first 80 post-gameplay Storm unlock helper hits, 76 returned to0x004c418e. - Related lock wrapper:
storm.dlllock helper entry0x1500cfc6, return0x1500d01b, exported via Storm ordinal#350and reached from StarCraft thunk0x004b5e1a. - Coarse EXE loop, not a frame boundary:
starcraft.exearound0x00474d02..0x00474d32calls the message pump at0x0043bb50and thenSleep(0). Low-overhead--countprobes hit these addresses constantly, but that only proves loop/message-pump cadence. - Actual game-step boundary:
the catch-up loop at
0x004410e1calls0x0043bb50, samples time throughebp, checks elapsed time against accumulator0x61fcc8, and consumes a step at0x004411db -> 0x004b2ed0. After that call it advances0x61fcc8by[0x695ab8+0x4d563c]. Count0x004b2ed0/0x004411dbfor logical game steps; count0x004410e1for outer loop iterations. - Derived elapsed-game timer, not the frame boundary:
inside
0x004b2ed0,0x004b2f79runs once per consumed step, but0x004b2fc7 -> 0x004b2fceincrements0x4fc4f0only every 16 steps via the0x68f7a0countdown. In the gameplay-only window the counts were 519 step hits and 33 elapsed-timer increments. - Non-display timing leads now rejected:
0x004cc049is the generic timer-dispatch edge.0x0049cc90is a 50 ms timed object/UI callback, no longer the best frame candidate. The0x004650d0..0x00465346Win32 timer/catch-up family is real clock work:0x00465140is installed withSetTimer(..., id=3, interval=0x14)and0x004652c6is the tightGetTickCountpoll, but its0x004652e7call to Storm ordinal#261lands in sound/WAVE state and is now rejected as a game frame boundary.0x0046ca60/0x0046ca76is now rejected too: it is a 250 ms network/player-message pump over eight slots, with Storm ordinal#122receive/status work and coarse status writes to0x695ed8. - Gameplay-only hot blocks, not frame boundaries:
/private/tmp/sc-hot-4550-4700.txtshows the main-thread hot set after mission entry is dominated by Storm loops,0x004b48aadecode/copy,0x004c4670display copy,0x0046201f/0x00461fe4pixel/tile scans, and0x00441d56terrain/blit helpers. These are the work done inside frames or display flushes, not the boundary that schedules a frame. - One-off EXE surface path:
0x004c7a1d/0x004c7a9dare a rare local Lock/Unlock pair observed once in the same trace, not the steady presenter.
Next probes
- Drive StarCraft into the same in-mission state as the screenshot with a small
enough
--batch-sizethat DirectDraw present intervals are resolved below one batch, then countwine.onGuestFramekinds while also sampling canvas hashes once per rAF. - Add a focused trace/counter for Storm ordinal
#356and compare it withdx_tracekind 5. If they match closely, the HUD can be relabeled more honestly as DirectDraw presents or primary unlock presents. - Trace
0x004411dbwith a small--batch-sizeand a canvas hash sampler to quantify how many DirectDraw presents each logical step produces in a live browser run. The current disassembly shows why multiple presents per step are plausible; this probe would put the ratio on the HUD path directly. - Use
--watch-logon0x631e8c,0x631e98, and0x631e9conly as clock plumbing. Confirm any proposed frame boundary against canvas hashes while Smacker counts and DirectSound/Storm#261counts remain flat.
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.
- Gameplay was confirmed by a PNG at batch 4795 (base, SCVs, 250/200, 12/42).
- The window is batches 1800-4800, which is 15 guest seconds of in-mission play.
- Each arm ran twice; ranges span the two rounds.
| 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:
- Pump pacing at 60 slows the game about 20%. StarCraft's game step runs on its own clock at about 22 per guest second, and it presents about 4.4 times per step (about 98 frame ends/s). Pacing those frame ends at 60, even pump-bounded, drops the game from 22.0 to 17.7 steps/s.
- Logical pacing at 60 costs no game speed. A cap of 60 on the step never binds, so the logical arm matches uncapped in steps/s.
- Logical pacing at 60 also saves no CPU here. Wall time is 82 s, the same as uncapped. The step never exceeds 60/s, and the ~98 presents/s (mostly repeated frames) are no longer throttled. The CPU the pump arm "saves" comes from the game running slower.
- At cap 15, both arms hold the step to about 17.7/s, not 15. The cause is the headless clock, not the pacer. The logical arm requested 16.2 s of sleep inside a 15 s window, so paced sleeps on the batch clock end early. Compare arms with each other, not against the cap.
- A bug the cap-15 run found, fixed in 183c181c. The marker decided
whether its own pace had slept by reading
$sleep_yielded. The host clears that flag only when it next checks the main thread. A stale 1 made the marker run on, and the sleep then landed at the next$runhalt, inside the step. Before the fix, 145 of 269 step sleeps were at the step; after, 263 of 263.$present_pumpstill uses the same flag test.