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-1 3126b9b0a390e046d96918e317fa64093ee29bbd) and its installer-produced stardated.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:

The next useful probe is therefore a gated one:

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

Useful command fragments:

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

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

The 900-batch Smacker-filter result was:

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

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

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

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

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

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

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

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

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

The node fields observed in disassembly are:

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

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

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

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

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

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

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

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

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

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

Two pause-oracle attempts are not usable as proof. Injecting F10 at batch 4550 made a byte-identical batch-4690 screenshot and nearly identical counters. 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

Next probes