Tool runs as fast as the machine allows
Status: built 2026-10-02. Read with ADR-0004 (replayable simulation), ADR-0007 (frames), m1-player.md §7–§8 (views and tours), m3-verification.md (L3) and phone-text.md §7 (L3's phone pass, which doubled L3).
A tool run (--tour, --replay, --shot) used to tick from Godot's physics loop at the game's
rate, as a person's game does, so a tour of 70 game seconds took 70 seconds or more, and longer
on llvmpipe, where a frame took longer than Godot's cap of 8 ticks a frame and the game fell
behind the clock. L3 plays every tour that names a shot, and every texted one twice (monitor and
phone), so every Director turn waited on it: Tideline's L3 player runs took 209 s, Fog Signal's
83 s (phone-text.md §7). The simulation is fixed-tick and reads no clock, so nothing in it needs
the wait.
1. What a shot depends on
A shot is drawn at a tick. For a tool run to step faster than real time without changing a pixel, what a shot shows must depend on the ticks that led to it and never on how many frames were drawn between them. The views, read for anything they carry from frame to frame:
| What | Carried from frame to frame | At a shot |
|---|---|---|
| kit.platformer | bodies and camera eased per tick (ticked) |
per tick already |
| kit.actors | each actor's memory and pose per tick (ticked) |
per tick already |
| kit.grid | a slide's progress from the tick it began | per tick already |
| kit.ui values | refreshed on the first frame of a new tick | read at the shot's tick |
| kit.ui message line | aged from the first refresh that showed it | a bug: see §3 |
| lit look: camera | eased per frame by the frame's own delta | snapped to its goal (settle) |
| lit look: sky, wind, flicker | from the hour and the tick | per tick already |
| lit look: light pool | a source keeps its light while it stays among the nearest | see §4 |
| kit.world_tiles chunks | streamed one a frame round the player, dropped two chunks out | built round the focus (settle) |
| kit.audio, kit.music | baked and faded per frame by wall time | never in a frame (and muted) |
| shaders | none reads TIME |
The player already treated --shot this way: it steps its idle ticks in a loop, calling
ticked() on every view, then puts every view at the last tick (_frame_now) and captures.
2. The options
Engine.time_scaleand more physics steps a frame. Rejected. How many ticks a frame steps would still follow the wall clock (Godot divides the frame's scaled time by the tick length, then caps it atmax_physics_steps_per_frame); every reader of a frame's delta (the camera's easing, the audio's bake budget and fades, the music's clock) would see a delta hundreds of times too big; and it is a global switch for a local need.- Many ticks a frame, presenting after each. Deterministic, and closest to what a person's run does, but every tick then pays for a frame nobody sees: kit.actors poses every model, the light pool and the chunk stream run, kit.audio bakes its share of the frame budget. And the renderer takes node changes in only when a frame is drawn, so it would not give the renderer a person's history either.
- Headless stepping between shots (chosen). A tool run on the desktop keeps its own clock:
GwPlayer._run_ahead, from_process, ticks back to back (the tour's or replay's frame, the step,ticked()on every view, the trace and the recording, exactly astick()always did) until the run stops for a frame (a shot, a boot's shader warm-up) or ends. Nothing presents between ticks. A shot then puts every view at its tick:present(1.0), then the newGwView.settle(), where a view finishes whatever it spreads over frames (the look snaps its camera to the goal, as_frame_nowdid; kit.world_tiles builds every chunk round the focus instead of one a frame)._physics_processsteps a person's game only.
Tests: gw_player_test.gd test_a_tool_run_keeps_its_own_clock_and_a_persons_run_the_games
(with the physics loop off, a tour of a minute of game time ends within five frames, on the same
state hash as the same tour stepped one tick() at a time; a person's game, and a watched tour,
step not at all from frames alone; before the change the tool run never moved), and
gw_world_view_test.gd test_a_shot_settles_every_chunk_round_the_focus (one chunk after a
frame, all nine after settle).
Not when someone watches. A tour or replay a person watches keeps the game's rate: --pace game, which tools/play.sh passes whenever it opens a window (its desktop renderer, the
default for a person), so a failing bot run is still "a replay the person can watch"
(m3-verification.md decision 1). Offscreen, as every tool and the ladder run it, the pace is
fast. A tour in the browser (tools/web.sh --tour, L5) keeps the game's rate too, as the page
a person plays does: the page has one thread, and the browser proof is there to show a person's
page plays the tour, audio and all. GwBootOptions.runs_ahead() says which. A person's run, on
the desktop or the web, is unchanged.
3. What it found: a message line aged from the frame that first drew it
kit.ui's message line fades over its secs by the game's ticks (m3-verification.md decision 9b),
but its birth was the first refresh that showed it, and a refresh happens on a frame. Stepping
without frames, the line was first drawn at the next shot and came up bright there: in the first
fast pass Tideline's poster, campfire, killed and web-beach drew the HUD line a step or
two brighter (1300 pixels, up to 68 levels in a channel).
The old loop was not right either. On llvmpipe a frame of the player took longer than 8 ticks,
so Godot stepped its cap of 8 ticks a frame and a frame came every 8 ticks: in Tideline's proof
tour the intro line, said at tick 61 as play begins, was first drawn, and so born, at tick 68
(249 physics frames in 31 drawn). At wake (tick 91) it was then still bright, where on a
machine drawing every tick it had stepped down to its ink. The frame depended on the machine.
Now GwUiView.ticked() stamps the line with the tick it came up (the first tick after it was
said that the game steps to or a frame draws, so a line said at boot is stamped at the boot's
frame), and the HUD label ages from the feed's tick (GwUiLabel.came_up). Test:
gw_ui_view_test.gd test_a_message_line_ages_from_the_tick_it_came_up_drawn_then_or_not
(red before: the line drawn only at the shot was bright, not fading).
4. What stayed
- The light pool's slots.
GwLightPool.assignkeeps a source on the light it had while it stays chosen, so which OmniLight3D holds which lamp depends on the order sources were first lent, frame by frame; with frames only at shots they are lent in the order they appear there. - Chunks two out. kit.world_tiles keeps chunks up to two away from the player and drops the rest, so which of those outer chunks exist depends on the path between shots. They are off the frame (3x3 chunks of 32 tiles cover the 27x18-tile camera with room to spare).
- The audio's bake. With no frames between shots kit.audio and kit.music bake only around shots. A tool run is muted (the Dummy driver); its log lines that time the bake by the clock ("asked for 7873 ms after bind") say less now, and never reach a result.
Neither of the first two changed a pixel of any gallery shot (§5). If one ever does, settle()
is the place to make it the same however the frames went.
5. Proof
The frames. Every tour of the eight gallery games (examples/*/playtests/**/*.tour, 26
tours) played through tools/play.sh offscreen; the 12 that take shots played again with
--phone 390x844: 38 runs, 272 PNGs (136 at 1280x720, 136 at 1920x1080), each run's result
line (state hash, ticks, every files[] row with its texts, screen and subjects).
| Compared | PNGs the same | Result lines the same |
|---|---|---|
| old player, real time, twice | 272 of 272 | 37 of 38 (a save, below) |
old player drawing a frame every tick (--fixed-fps 60) vs fast |
272 of 272 | 37 of 38 (a save, below) |
| old player, real time, vs fast | 264 of 272 | 38 of 38 |
The old player made to draw a frame every tick (Godot's --fixed-fps 60: one tick a frame, as
a machine fast enough draws it) gives the fast run's frames byte for byte. Against the old
player in real time on llvmpipe, 8 PNGs differ: Tideline proof's wake and Thornwick look's
quest, each with its without-player frame, on the monitor and the phone. Each is the HUD's
message line one step brighter in the old real-time frame (§3: born when a frame first drew it).
Every result line matches, text rows and subject measurements included. The one line that
differs elsewhere is Tideline's web/reload tour, which loads the save web/walk leaves in the
checkout's user folder: it fails in a fresh copy that has not played walk yet, whatever the
code.
L3 itself, before and after on fresh copies of the eight games: 261 PNGs each (monitor and phone frames, subject passes, the model's small copies), 253 the same; the 8 that differ are the same two shots' message lines. Every rung's report line is the same.
The times. verify --level L3 on a fresh copy, L0 to L3, inside one tools/heavy.sh slot
with its jobs run one by one (GAMEWRIGHT_HEAVY empty), before and after back to back on a box
busy with other sessions:
| Game | Before | After |
|---|---|---|
| Tideline | 311.4 s | 139.8 s |
| Thornwick | 319.1 s | 163.9 s |
| Nightcoast | 166.4 s | 142.9 s |
| Fog Signal | 93.1 s | 30.8 s |
| Burrow Run | 62.9 s | 33.5 s |
| Lamplighter | 61.3 s | 60.7 s |
| Acorn Rooftops | 40.7 s | 38.7 s |
| Pier Pressure | 25.9 s | 22.1 s |
| all eight | 1081 s | 632 s |
Games whose L3 tour is short (Lamplighter, Acorn Rooftops, Pier Pressure) spend their time in L1, L2 and the player's boot; Nightcoast's L2 bots are most of its time.
The 38 tour runs above, each through tools/play.sh, took 1267 s and 1667 s in the old player's
two passes and 671 s in the fast one over the same busy hour (340 s again later, on a quieter
box). By tour, old then fast: Tideline proof (4591 ticks) 121 s, 78 s; Thornwick look (4964) 144
s, 83 s; Thornwick proof (4482, no shots) 151 s, 35 s; Fog Signal look (1375) 30 s, 7.5 s; Burrow
Run look (1014) 24 s, 11 s; Acorn Rooftops' web proof (2113, no shots) 40 s, 6.9 s. A tour of no
shots is now mostly its boot.
The web proof's desktop comparison (tools/web.sh: engine-cli sim --tour) never used the
player: it was already headless, as fast as the CPU allows, and is unchanged (Acorn Rooftops'
proof tour 2.7 s before, 2.6 s after; the same state hashes). tools/web.sh (the toy, a person's
visit, the proof tour and the reload) passes 27 of 27 checks and ends on the desktop's state.
Where a fast run's time goes now. Tideline's proof tour alone, 55 s: about 18 s of boots
(the bundle and world, and a shader warm-up of 1.2 to 2.2 s for each of its four boots), about
25 s for 13 shots of 40 PNGs on llvmpipe (0.7 s a frame at 1280x720, and the subject passes),
and about 13 s for its 4591 ticks, half of that in 250 ticks of goto prop: (below).
6. Not yet
goto prop:costs 25 ms a tick in Tideline:GwGotoDriver._targetlooks through every prop within 512 m for the nearest that isn't worked out, twice a tick. Remembering the target would change which prop a walk ends at, so it needs its own design and its own proof.- A warm-up for every boot. A tool run waits for the shader warm-up (one program a frame) after each boot, load or quit to the title; nothing in a shot needs it, since the shot's own draw would compile what it uses. Skipping it in tool runs would save 1 to 2 s a boot.
- heavy.sh's gate. Run as the core runs it, each L3 player run waits for its own slot (three readings 10 s apart): about 20 s a run on a quiet box, more on a busy one, now often more than the run itself.
- The browser. A tour there still plays at the game's rate (§2).