diff --git a/coworld/tools/test_browser_with_playwright.py b/coworld/tools/test_browser_with_playwright.py index 93d4ec02..bbeef78f 100644 --- a/coworld/tools/test_browser_with_playwright.py +++ b/coworld/tools/test_browser_with_playwright.py @@ -182,7 +182,25 @@ def check_control(base, executable, output, gpu=False): frame.evaluate(replay + '.pause()') assert state['playing'] and state['speed'] == 2 and state['tick'] > first, state report['ticks_per_second_at_2x'] = (state['tick'] - first) / 2 - print('control: speed passed', flush=True) + # Fast speeds deliver what they report: frames slower than ticks + # carry a larger backlog instead of dropping time. + for speed in (4, 16): + assert frame.evaluate(f'{replay}.setSpeed({speed})') == speed + frame.evaluate(replay + '.play()') + frame.evaluate('nextFrames(5)') + first = frame.evaluate(replay + '.state().tick') + started = time.monotonic() + page.wait_for_timeout(3000) + state = frame.evaluate(replay + '.state()') + elapsed = time.monotonic() - started + frame.evaluate(replay + '.pause()') + achieved = (state['tick'] - first) / elapsed / 24 + report[f'achieved_speed_at_{speed}x'] = round(achieved, 2) + assert state['playing'] and state['tick'] > first, state + if gpu: + assert achieved >= speed * 0.85, report + frame.evaluate(replay + '.setSpeed(2)') + print('control: speed passed', {k: v for k, v in report.items() if 'speed' in k}, flush=True) # A frozen camera holds still while playing; projection round-trips. frame.evaluate(replay + ".camera({mode: 'fixed', x: 0, z: 0, distance: 40})") diff --git a/docs/replay-control.md b/docs/replay-control.md index 5bef0133..57887be4 100644 --- a/docs/replay-control.md +++ b/docs/replay-control.md @@ -40,7 +40,7 @@ GotA that is `simulation / WorldScale`, centred on the map, with y up. | `seek(tick, seekId = 0)` | Starts an exact seek and keeps the play state. Frames report `seekId` and `seekTick` until another seek replaces it; the seek has landed when `seekId` matches, `seeking` is false and `tick === seekTick`. | | `cancelSeek()` | Stops a seek, including a pending backward restore, on the last simulated tick. | | `play()`, `pause()` | Play rewinds first when already at the end. | -| `setSpeed(x)` | Applies the fastest of 1, 2, 4 or 16 at or below `x` and returns it. | +| `setSpeed(x)` | Applies the fastest of 1, 2, 4 or 16 at or below `x` and returns it. Playback runs at that speed while the machine keeps up: slow frames simulate several ticks each, within the seek slice, and playback drops time only after falling a quarter second of wall time behind. | | `setLoop(on)`, `setSeekSlice(ms)` | As the URL options. | | `camera({mode, ...})` | `director`; `fixed` with `x`, `z` and optional `distance`; `follow` with unit `id`; `freeze` holds the current view. Every mode except `director` stops the automatic camera. | | `freezeCamera()` | Same as `camera({mode: 'freeze'})`. | @@ -98,6 +98,7 @@ transport changes and the exports natively. The browser test (`coworld/tools/test_browser_with_playwright.py`, or `--control-only` for just this part) checks a cold seek to the last tick for progress, exact seeks, cancel, pause, speed, frozen cameras, projection round trips, picking, the postMessage -bridge and embed input handling. Page responsiveness during the seek is asserted +bridge and embed input handling. With `--gpu`, 4× and 16× playback must reach +85% of the requested speed. Page responsiveness during the seek is asserted only with `--gpu` (hardware rendering through Metal): SwiftShader takes seconds per frame late in a match, so there the timer gaps are only recorded. diff --git a/src/polyworld/player.nim b/src/polyworld/player.nim index 06805908..6f5c5adc 100644 --- a/src/polyworld/player.nim +++ b/src/polyworld/player.nim @@ -13,6 +13,10 @@ const TransportHeight* = 80.0'f32 PlayerSpeeds* = [1'i32, 2, 4, 16] CatchUpSeconds* = 0.1 + PlaybackLagSeconds* = 0.25 + ## Wall-clock time realtime playback may fall behind before it drops + ## time. Scaling the simulated backlog by speed lets fast speeds keep up + ## when frames are slower than ticks. IconSize = 64.0'f32 IconGap = 8.0'f32 GroupGap = 24.0'f32 @@ -203,7 +207,7 @@ proc startFrame*(player: var Player, dt: float32, tickRate: int32) = return player.accumulator = min( player.accumulator + dt * player.speed.float32, - 0.25'f32 + PlaybackLagSeconds * player.speed.float32 ) proc shouldTick*( @@ -211,8 +215,8 @@ proc shouldTick*( frameStart: float64 ): bool = ## Returns whether one more simulation tick belongs in this frame. - ## Seeks yield after `catchUpSeconds` so the page stays responsive, and - ## paused seeks stop exactly on their target. + ## Seeks and realtime playback yield after `catchUpSeconds` so the page + ## stays responsive, and paused seeks stop exactly on their target. if player.reachedEnd: if player.repeating: player.seekTo(0, automatic = true) @@ -228,7 +232,8 @@ proc shouldTick*( if not player.playing: return false let step = 1.0'f32 / max(player.tickRate, 1).float32 - if player.accumulator >= step: + if player.accumulator >= step and + epochTime() - frameStart < player.catchUpSeconds: player.accumulator -= step return true false diff --git a/tests/test_player.nim b/tests/test_player.nim index 548d34c0..09b3677b 100644 --- a/tests/test_player.nim +++ b/tests/test_player.nim @@ -61,9 +61,36 @@ block: p.startFrame(0.02, 24) doAssert p.takeRestore() == -1 doAssert p.accumulator > 0 - doAssert not p.shouldTick(0) + doAssert not p.shouldTick(epochTime()) p.startFrame(0.03, 24) - doAssert p.shouldTick(0) + doAssert p.shouldTick(epochTime()) + +echo "Testing fast playback keeps up when frames are slow" +block: + var p = initPlayer(live = false, durationTicks = 10000, speed = 16) + p.sync(0, 10000, false) + # Ten frames a second at 16x owe 1.6 s, 38 ticks, each frame. + p.startFrame(0.1, 24) + var ticks = 0 + while p.shouldTick(epochTime()): + inc ticks + doAssert ticks == 38 + # The backlog stops at a quarter second of wall time. + for i in 0 ..< 10: + p.startFrame(0.1, 24) + doAssert p.accumulator == PlaybackLagSeconds * 16 + p.setSpeed(0) + p.startFrame(0.1, 24) + doAssert p.accumulator == PlaybackLagSeconds + +echo "Testing realtime playback yields after the frame budget" +block: + var p = initPlayer(live = false, durationTicks = 10000, speed = 16) + p.sync(0, 10000, false) + p.startFrame(0.1, 24) + let owed = p.accumulator + doAssert not p.shouldTick(epochTime() - p.catchUpSeconds - 0.01) + doAssert p.accumulator == owed echo "Testing play/pause stops catch-up" block: