Heroes II demo: why it feels slow (2026-10-04, CLAUDE-HEROES2-PERFORMANCE)

Answer: the hero walk is slow because of pacing, not CPU. The walk runs about 5x slower in the browser than it should, and the cause is the clock-spin parker ($spin_park_k, K=8). With K=0 set live in the page, the same 4-tile walk completes in about 1.2 s instead of about 5.9 s.

During the walk the interpreter is almost idle: it retires about 0.6M blocks/s, against the 37–40M blocks/s it sustains on the idle map. Presentation costs about 1% of the main thread.

This is investigation only. No source was changed and nothing was rebuilt. Evidence is in scratch/claude-heroes2-performance-20261004/ (the runtime ledger is runtime-ledger.json: 516.7 s of 600 used, every run serialized).

Identity

git HEAD 16f764ad7c5a with 371 dirty paths of shared WIP (host.js and lib/* included)
module build/wine-assembly.wasm sha256 f40d4ca3382279ff9b826188573f8acd9272eaa2dc5024dcbb69aecc35b49063 (mtime 2026-10-03 17:46)
build status Not rebuilt; every CLI run used --no-build. src/09a8 is newer than the module (uncompiled WIP), so it is not what ran.
browser module The page loads the same prebuilt artifact (host.js 2711). Its logged SHA was not captured.
assets All 14 heroes2DemoFiles are present, hashed in identity.txt. H2DEMOW.EXE is d1ae170c…, HEROES2.AGG is f469a8f6… (43,362,148 B).
host Linux 6.8 x86_64, 4 cores, node v24.18.1, /usr/bin/google-chrome (headless, via tools/web-input-probe.js)
load 0.13–1.19 (1-min) before and after every run; per-run values are in the ledger

Scenes and commands

The CLI route is the one from test/test-heroes2-scroll-gameplay.js: NEW GAME → STANDARD → OKAY, then hero screen, path, walk and auto-scroll. It is saved in route-input.txt. Every scene was checked with a screenshot in png/.

Diagnosis

1. Hero walk is pacing-bound, caused by the clock-spin park (decisive)

Browser timeline: browser/b6-samples.txt (K=8) against b7-samples.txt (K=0). Same route, same 4-tile path plus scroll, same end frame.

K=8 (default) K=0
wall time from walk click until presents fall back to the 9/s idle rate ≈5.9 s ≈1.2 s
guest presents during the walk 61 (10.4/s) 51 in about 1.2 s (35 of them in the first 0.5 s)
blocks retired during the walk 3.4M (≈0.6M/s, about 1.5% of capacity) about 38M/s, continuous
clock-spin parks during the walk 1554 (265/s, one every 3.8 ms; ≈25 per present) 0
guest GetTickCount minus performance.now() constant (−324 ms) constant

2. The idle adventure map busy-polls at full speed and is never parked

3. Interpreter CPU is not the limiter on this box

4. Presentation and audio are minor

5. The default CLI clock is misleading for this app

At 200 ms/batch the guest is starved: 94% of batches spend the full budget, at 4.3 presents per guest-s (r1/r2). Its block mix is roughly "render as fast as the budget allows". Handler histograms from that clock, like those in heroes2-demo.md and dispatch-attribution-2026-09.md, describe throughput work, not what a player waits on.

Ranked improvements

  1. Park only clock waits the host can honour. For example, skip the park when the spin's owed time is shorter than the timer clamp (owed < 4 ms). Alternatively, wake short parks with a clamp-free macrotask (MessageChannel) instead of nested setTimeout.
    • Payoff: the walk goes from about 5.9 s to about 1.2 s per 4 tiles (measured upper bound, K=0). This is the user-visible lever.
    • Risk: it gives back part of the menu-idle CPU win from the 2026-09-10 MRU-context fix. That menu's deadlines are +13/+30/+110 ms, so a "long waits only" rule should keep most of it.
    • Validate:
      • The b5/b7 walk timeline in a headful browser, with three arms: K=8, the variant, and K=0.
      • Menu and idle renderer CPU, headful.
      • test-clock-spin-park.js and test-clock-spin-contexts.js.
      • Diablo II re-checked, given the precedent.
  2. Park the idle-map widget-broadcast poll. One option is a per-context "same clock value plus no input or message change" park at K≈3, which the census measured as 4.6x fewer API calls.
    • Payoff: power and heat, and main-thread headroom (up to about 70% of a core) while idle.
    • Not a payoff: frame rate. The idle map presents at the game's own ~9/s, its 110 ms timer.
    • Risk: false parks. Gate on frame equality and present counts, as docs/frame-pacing-census.md demands for any K change.
  3. uop coverage for the poll and broadcast loop: a guarded call [eax+8] (ICG), plus no-backedge heads 0x4d3280/0x4c5a20.
    • Payoff: it only matters on slow devices. It would make the idle spin cheaper per block; with item 2 done, it is nearly moot.
    • Requirement: native disassembly from both V8 and SpiderMonkey before any variant (CLAUDE.md).
  4. Do not chase presentation (about 1%), decode (zero live evictions, ≤5000 decodes per phase) or the sprite decoder (already a uop program).

Next validation experiment

Run three arms in a headful browser, one at a time, at low load:

Use the b5 route and sampler (browser/sample2.js every 250 ms). For each arm, record:

Then repeat on a phone-class CPU (--cpu=4 throttle) to check that removing parks does not starve audio.

Unresolved / not measured