StarCraft shareware
Package update (2026-09-10): the app profile now uses Blizzard's official 29,569,755-byte electronic-download demo (
SCDemo.exe, SHA-13126b9b0a390e046d96918e317fa64093ee29bbd) and its installer-producedstardated.mpq, rather than the separate five-mission shareware CD build. The official ED executable has different addresses. Its logical-frame counter has now been re-profiled below; unless explicitly marked "official ED," the older CD-specific disassembly and measurements remain historical context. The retimed ED route reaches unobstructed Terran mission gameplay at batch 1100 with a 200,000-step batch: Start at(545,393)around batch 700 and dismiss the tip at(200,263)around batch 900.
Official ED logical frame counter (2026-09-10)
The official electronic demo preserves the CD build's accumulator-driven catch-up-loop structure, with relocated code and globals:
0x004410f1 call 0x0043bb70 ; outer message/timer pump
...
0x00441154 sub esi, [0x61fc28] ; elapsed versus simulated-time accumulator
...
0x004411eb call 0x004b29f0 ; consume one logical game step
0x004411f7 mov ecx, [0x695a18] ; post-call verifier
0x00441205 mov al, [ecx+0x4d563c] ; current mode's step quantum
0x0044120b add edx, eax
0x0044120d mov [0x61fc28], edx ; advance simulated time
A canonical threaded run reached unobstructed Terran mission gameplay at batch 1040 and produced these passive block-dispatch counts:
| Counter | Hits | Interpretation |
|---|---|---|
0x004410f1 |
4140 | Outer scheduler/catch-up loop; not a logical frame. |
0x004b29f0 |
763 | Logical game-step service entry. |
0x004411f7 |
763 | Post-call accumulator path; independent 1:1 verifier. |
0x004b2a99 |
482 | Conditional work inside the service; not every step. |
0x004b2cb7 |
763 | Common service exit path. |
dx_present |
8169 | Primary-surface present events; much more frequent. |
Therefore starcraft_shareware.perf.logicalFrame uses primary address
0x004b29f0 and verifier 0x004411f7. The HUD reports this as GAME/s
beside generic PRESENT/s. The verifier is a consistency check, not a second
rate: it should remain 1:1 with the primary counter.
test/binaries/candidates/starcraft-demo-official/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? | official ED 0x004b29f0, verified at 0x004411f7 |
The accumulator-driven game-step boundary. DirectDraw unlocks remain only a display-work 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.
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.
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.
Historical CD-build browser measurement: the profile formerly used primary
counter 0x004b2ed0 and verifier 0x004411e7. Those addresses must not be
used with the official ED executable. Its current profile uses the relocated
pair documented at the top of this note.
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.