From f4ab63fd3a1f2f973c161ee40cce5e32fef24810 Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Sat, 3 Oct 2026 19:54:35 +0300 Subject: [PATCH 1/4] feat: Add InputTapeRecorder.truncate for a run rewound and gone on A recorder now keeps only the first so many entries on request, so a tape can be cut where a branch left the old future and recorded on from there. Truncating to nothing makes the next entry a first one again. --- packages/flutter3d_sim/CHANGELOG.md | 2 + .../lib/src/input/input_tape.dart | 13 ++++++ .../flutter3d_sim/test/input_tape_test.dart | 42 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/packages/flutter3d_sim/CHANGELOG.md b/packages/flutter3d_sim/CHANGELOG.md index ef36a34c6..a98de9363 100644 --- a/packages/flutter3d_sim/CHANGELOG.md +++ b/packages/flutter3d_sim/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- **`InputTapeRecorder.truncate`** keeps the first so many entries, for a + run rewound and gone on from an earlier step. - **A `.f3drun` carries the levels edited under the run.** `Demo.levelSwaps` holds each one as a `DemoLevelSwap`: the step it took effect before and the whole document, since the edited level exists in no diff --git a/packages/flutter3d_sim/lib/src/input/input_tape.dart b/packages/flutter3d_sim/lib/src/input/input_tape.dart index 341b84d9d..d8550049e 100644 --- a/packages/flutter3d_sim/lib/src/input/input_tape.dart +++ b/packages/flutter3d_sim/lib/src/input/input_tape.dart @@ -173,6 +173,19 @@ final class InputTapeRecorder { final InputTape tape; + /// Forgets every entry from step [steps] on, keeping the first [steps]. + /// + /// For a run rewound and gone on from an earlier step: the entries after + /// it describe a future that is not going to happen, and the next [record] + /// writes the step the run takes instead. Truncating to nothing makes the + /// next entry a first one again, carrying what is held as presses. Asking + /// to keep more than the tape has keeps it whole. + void truncate(int steps) { + RangeError.checkNotNegative(steps, 'steps'); + final frames = tape.frames; + if (steps < frames.length) frames.removeRange(steps, frames.length); + } + void record(InputState input) { // The first entry carries what was already held, as presses. A recording // that begins while the player is walking forward begins after the press diff --git a/packages/flutter3d_sim/test/input_tape_test.dart b/packages/flutter3d_sim/test/input_tape_test.dart index 3d6c39b6a..9295abd9d 100644 --- a/packages/flutter3d_sim/test/input_tape_test.dart +++ b/packages/flutter3d_sim/test/input_tape_test.dart @@ -192,6 +192,48 @@ void main() { expect(replayed.held(_jump), isFalse); }); + test('truncated and recorded on, replays the run that went on', () { + // A run rewound to step 20 and played on differently from there: the + // tape cut at 20 and recorded on has to replay to where the second + // run went, not the first. Mutation: make `truncate` a no-op — the + // tape keeps the old future's 20 entries ahead of the new ones and + // replays forty steps into somewhere else. + final recorder = InputTapeRecorder(seed: 5); + final input = InputState(); + for (var i = 0; i < 40; i++) { + _play(input, i); + recorder.record(input); + input.endStep(); + } + recorder.truncate(20); + expect(recorder.tape.steps, 20); + + final again = InputState(); + final live = _Toy(5); + for (var i = 0; i < 20; i++) { + _play(again, i); + live.step(again); + } + for (var i = 100; i < 120; i++) { + _play(again, i); + recorder.record(again); + live.step(again); + } + + final replayed = _Toy(5); + final replayInput = InputState(); + final playback = InputTapePlayback(recorder.tape); + while (!playback.isFinished) { + playback.applyTo(replayInput); + replayed.step(replayInput); + } + expect(replayed.ending, live.ending); + + recorder.truncate(100); + expect(recorder.tape.steps, 40, reason: 'more than it has keeps all'); + expect(() => recorder.truncate(-1), throwsRangeError); + }); + test('and an idle step says so', () { // What a compressor would key on later, and what makes "a few bytes a // second" true rather than aspirational. From 015f67b6b5ecb97c49fa94fe7e18b320565c9c4b Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Sat, 3 Oct 2026 19:54:35 +0300 Subject: [PATCH 2/4] feat: Make paused steps and branches keep the run's tapes RunTimeline.stepOnce at the present stepped the simulation without writing the input to any recorder or taking a due keyframe, so a rewind or branch afterwards replayed to a state the run never had. It now writes to recorders (the loop's list, or the rewind buffer's recorder by default), keyframes through capture and runs onStepped after the step. releaseAt and branchHere report the step they cut to and how far back it was through onBranched. DemoRecording.branched truncates the demo's tape there, forgets later checkpoints and drops swaps after the step, so a saved demo replays the branch that was played rather than the old future. --- packages/flutter3d_game/CHANGELOG.md | 10 ++ .../lib/src/run/demo_recording.dart | 33 +++++- .../lib/src/run/run_timeline.dart | 53 ++++++++- .../test/demo_recording_test.dart | 89 +++++++++++++++ .../test/run_timeline_test.dart | 105 ++++++++++++++++++ 5 files changed, 287 insertions(+), 3 deletions(-) diff --git a/packages/flutter3d_game/CHANGELOG.md b/packages/flutter3d_game/CHANGELOG.md index f868e46d1..b63f0e243 100644 --- a/packages/flutter3d_game/CHANGELOG.md +++ b/packages/flutter3d_game/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +- **`RunTimeline.stepOnce` at the present is a step the run keeps.** It + writes the input to `recorders` (pass `GameLoop.recorders`; the rewind + buffer's recorder alone by default), takes the keyframe that falls due + through `capture`, and runs `onStepped` after, so a rewind or branch past + paused steps no longer replays without them. +- **A branch cuts the demo too.** `RunTimeline(onBranched:)` hears the step + `releaseAt` or `branchHere` cut to and how far back that was; + `DemoRecording.branched(stepsAgo:)` truncates the tape there, forgets the + checkpoints after it and drops a level swap after it. + `DemoRecording.levelSwaps` lists the swaps so far. - **An edit made under a running game goes into its demo.** `DemoRecording` is the run being written down: start, recorder, checkpoints and the levels swapped in, with `levelSwapped` turning the diff --git a/packages/flutter3d_game/lib/src/run/demo_recording.dart b/packages/flutter3d_game/lib/src/run/demo_recording.dart index fb748bf27..390dcd6c2 100644 --- a/packages/flutter3d_game/lib/src/run/demo_recording.dart +++ b/packages/flutter3d_game/lib/src/run/demo_recording.dart @@ -73,6 +73,37 @@ final class DemoRecording { return true; } + /// Writes down that the run was rewound [stepsAgo] steps and goes on from + /// there — `RunTimeline.onBranched`'s count, as [levelSwapped] takes its + /// own. + /// + /// The tape is cut at that step ([InputTapeRecorder.truncate]) and the + /// checkpoints after it forgotten: both describe the future the branch + /// left. A checkpoint at the step itself stays, since the state after + /// that many steps is the state the run goes on from. So does a swap at + /// that step, which took effect before it and is still the level on + /// screen; a swap after it belonged to the future and goes. (A timeline + /// cannot branch back past a swap it made, since `RunTimeline.swapLevel` + /// rebases its buffer there; the rule is for a caller that can.) + /// + /// False, and nothing changed, when the branch went back before this + /// recording began: the start it holds is then a state the run no longer + /// passes through. The caller begins a new recording from the present, as + /// for [levelSwapped]. + bool branched({required int stepsAgo}) { + RangeError.checkNotNegative(stepsAgo, 'stepsAgo'); + final at = recorder.tape.steps - stepsAgo; + if (at < 0) return false; + recorder.truncate(at); + _swaps.removeWhere((swap) => swap.step > at); + checkpoints.forgetAfter(at); + return true; + } + + /// The levels swapped in under the run so far, oldest first. + List get levelSwaps => + List.unmodifiable(_swaps); + /// The run so far, as a file. Demo demo({ required String buildStamp, @@ -87,7 +118,7 @@ final class DemoRecording { checkpoints: checkpoints, platform: platform, recordedBy: recordedBy, - levelSwaps: List.unmodifiable(_swaps), + levelSwaps: levelSwaps, ); } diff --git a/packages/flutter3d_game/lib/src/run/run_timeline.dart b/packages/flutter3d_game/lib/src/run/run_timeline.dart index 67da57de8..bbd55f842 100644 --- a/packages/flutter3d_game/lib/src/run/run_timeline.dart +++ b/packages/flutter3d_game/lib/src/run/run_timeline.dart @@ -228,8 +228,12 @@ final class RunTimeline { required this.input, required this.stepSim, required this.restore, + List? recorders, + this.capture, + this.onStepped, + this.onBranched, this.stepSeconds = 1.0 / 60.0, - }); + }) : recorders = recorders ?? [rewind.recorder]; /// Where the recent past is kept, and what [releaseAt] cuts. final RewindBuffer rewind; @@ -244,6 +248,31 @@ final class RunTimeline { /// Puts a snapshot back into the live objects. final void Function(Snapshot snapshot) restore; + /// Where [stepOnce] at the present writes the step's input, as the loop + /// writes a step it runs itself. + /// + /// **The loop's own list, where there is a loop** — `GameLoop.recorders`, + /// passed as the same object, so a demo's recorder added to the loop after + /// this timeline was made is written to by a paused step too. Without one, + /// [rewind]'s recorder alone. + final List recorders; + + /// The live state, for the keyframe [stepOnce] at the present takes when + /// one falls due — the snapshot a game's step hands [RewindBuffer.keyframe] + /// before it simulates. Null takes none: the tape still gets the step, and + /// a rewind past it plays from the keyframe before. + final Snapshot Function()? capture; + + /// Runs after [stepOnce] at the present has stepped: whatever a game does + /// once a step after the simulation, such as `DemoRecording.observe`. + final void Function()? onStepped; + + /// Told when [releaseAt] or [branchHere] has cut the future: the step the + /// run now goes on from, and how many steps before the old present that + /// was — the count `DemoRecording.branched` takes, since the demo's tape + /// and this buffer do not start at the same step. Called after the cut. + final void Function(int step, {required int stepsAgo})? onBranched; + /// The fixed step, in seconds, [stepOnce] and [releaseAt] advance by. final double stepSeconds; @@ -282,6 +311,14 @@ final class RunTimeline { /// From a scrub this moves the scrub one step along the tape rather than /// stepping the simulation off it, so "step" in a debugger walks the /// recorded run; at the present the scrub ends. + /// + /// **At the present, a step the run keeps.** The input is written to every + /// one of [recorders] before the step, a keyframe is taken through + /// [capture] when [RewindBuffer.keyframeDue] says so, and [onStepped] runs + /// after — the moments `GameLoop` and a game's step keep for a step they + /// run. A step taken here and not written down would leave the tape short + /// of the state, and a rewind or branch afterwards would replay to + /// somewhere the run never was. void stepOnce() { if (!_paused) { throw StateError('stepOnce is only valid while the timeline is paused'); @@ -292,8 +329,14 @@ final class RunTimeline { _history.add(const TimelineStepped()); return; } + for (var r = 0; r < recorders.length; r++) { + recorders[r].record(input); + } input.beginStep(); + final live = capture; + if (live != null && rewind.keyframeDue) rewind.keyframe(live()); stepSim(stepSeconds); + onStepped?.call(); input.endStep(); _history.add(const TimelineStepped()); } @@ -312,7 +355,10 @@ final class RunTimeline { /// the buffer at [point]. The timeline is left running (not paused): a /// release is asking to keep playing from here, not to pause on arrival — /// call [pause] afterwards for that. + /// + /// [onBranched] hears the step cut to, after the cut. void releaseAt(RewindPoint point) { + final from = rewind.step; _forgetScrub(); restore(point.snapshot); final toPoint = InputTapePlayback(point.tapeToPoint); @@ -331,6 +377,7 @@ final class RunTimeline { rewind.cut(point); _paused = false; _history.add(TimelineBranched(point.step)); + onBranched?.call(point.step, stepsAgo: from - point.step); } /// Replaces the level under the run without the run jumping: [swap] puts @@ -533,7 +580,7 @@ final class RunTimeline { /// /// **Stays paused**, unlike [releaseAt]: the person branching is looking /// at the moment they chose, and the first step of the new branch is - /// theirs to take. + /// theirs to take. [onBranched] hears the step, as from [releaseAt]. ScrubAnswer branchHere() { final at = _scrubbedAt; final point = at == null ? null : rewind.rewindTo(at); @@ -543,9 +590,11 @@ final class RunTimeline { 'from; scrub to a step first', ); } + final from = rewind.step; rewind.cut(point); _forgetScrub(); _history.add(TimelineBranched(at)); + onBranched?.call(at, stepsAgo: from - at); return ScrubMoved(at); } diff --git a/packages/flutter3d_game/test/demo_recording_test.dart b/packages/flutter3d_game/test/demo_recording_test.dart index 2dda76bec..e68b0a2ce 100644 --- a/packages/flutter3d_game/test/demo_recording_test.dart +++ b/packages/flutter3d_game/test/demo_recording_test.dart @@ -142,6 +142,95 @@ void main() { expect(live.demo.checkpoints.steps, [10, 20, 30, 40, 60]); }); + test('a run branched K steps back and recorded on replays to where the ' + 'branch went', () { + // The game's wiring: the loop's recorders handed to the timeline, the + // checkpoint after each step, and the branch written into the demo. + final toy = _Toy(); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, keyframeEvery: 20); + final demo = DemoRecording( + level: 'assets/levels/toy.json', + levelHash: _level(1.0).digestHex, + start: toy.save(), + seed: 0, + checkpointEvery: 10, + ); + final recorders = [rewind.recorder, demo.recorder]; + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => toy.step(input), + restore: toy.restore, + recorders: recorders, + capture: toy.save, + onStepped: () => demo.observe(toy.save), + onBranched: (step, {required stepsAgo}) => + expect(demo.branched(stepsAgo: stepsAgo), isTrue), + ); + void live(int from, int to, void Function(InputState, int) play) { + for (var step = from; step < to; step++) { + play(input, step); + for (final recorder in recorders) { + recorder.record(input); + } + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(toy.save()); + toy.step(input); + demo.observe(toy.save); + input.endStep(); + } + } + + live(0, 100, _play); + // Paused and stepped, as a debugger would: these steps are the run's. + timeline.pause(); + for (var step = 100; step < 110; step++) { + _play(input, step); + timeline.stepOnce(); + } + expect(demo.steps, 110); + expect(timeline.releaseAtStep(73), isTrue); + // Mutation: make `branched` a no-op — the tape keeps the 37 steps of + // the old future, and the branch's are written after them. + expect(demo.steps, 73, reason: 'the old future is cut from the tape'); + // The branch goes elsewhere: the stick the other way. + live(73, 130, (input, step) => input.setStickAxis(-1.0, 0.0)); + + final file = _sent(demo); + expect(file.steps, 130); + expect(file.checkpoints.steps, [ + for (var s = 10; s <= 130; s += 10) s, + ], reason: 'the checkpoints up to 70 kept, the branch writing the rest'); + final replayed = _replay(file); + // The independent check: the file, read back, plays to the live run. + expect(replayed.replay.divergence, isNull); + expect(replayed.replay.steps, 130); + expect(replayed.toy.x, toy.x); + }); + + test('a branch keeps a swap at the branch step and drops one after it', () { + final demo = DemoRecording( + level: 'assets/levels/toy.json', + levelHash: _level(1.0).digestHex, + start: _Toy().save(), + seed: 0, + ); + for (var step = 0; step < 30; step++) { + demo.recorder.record(InputState()); + } + expect(demo.levelSwapped(_level(2.0), stepsAgo: 20), isTrue); + expect(demo.levelSwapped(_level(3.0), stepsAgo: 5), isTrue); + + // Mutation: drop swaps at the step too — the level that is still on + // screen would go missing from the file. + expect(demo.branched(stepsAgo: 20), isTrue); + expect(demo.steps, 10); + expect(demo.levelSwaps.map((swap) => swap.step), [10]); + expect(demo.branched(stepsAgo: 11), isFalse, reason: 'before the start'); + expect(demo.steps, 10); + }); + test('a swap from before the recording began is not written into it', () { final demo = DemoRecording( level: 'assets/levels/toy.json', diff --git a/packages/flutter3d_game/test/run_timeline_test.dart b/packages/flutter3d_game/test/run_timeline_test.dart index fb0420b08..319edbb1e 100644 --- a/packages/flutter3d_game/test/run_timeline_test.dart +++ b/packages/flutter3d_game/test/run_timeline_test.dart @@ -118,6 +118,111 @@ void main() { }); }); + group('stepOnce at the present', () { + /// Two seconds live, then paused and stepped one step at a time to + /// [to], each paused step driven by `_play` the way a person holding a + /// key would. + ({_Toy toy, RewindBuffer rewind, RunTimeline timeline, InputState input}) + stepped({ + required int to, + List? recorders, + void Function(int step, {required int stepsAgo})? onBranched, + }) { + final toy = _Toy(11); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => toy.step(input), + restore: toy.restore, + recorders: recorders, + capture: toy.save, + onBranched: onBranched, + ); + for (var step = 0; step < 120; step++) { + _play(input, step); + rewind.recorder.record(input); + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(toy.save()); + toy.step(input); + input.endStep(); + } + timeline.pause(); + for (var step = 120; step < to; step++) { + _play(input, step); + timeline.stepOnce(); + } + return (toy: toy, rewind: rewind, timeline: timeline, input: input); + } + + String freshTo(int steps) { + final toy = _Toy(11); + final input = InputState(); + for (var step = 0; step < steps; step++) { + _play(input, step); + input.beginStep(); + toy.step(input); + input.endStep(); + } + return toy.state; + } + + test('writes the step into the tape, so a rewind and release afterwards ' + 'lands where the stepped run was', () { + // Mutation: drop the recorders loop in `stepOnce` — the buffer stays + // at step 120, the release to 150 is refused, and the run is not + // where a fresh run to 150 is. + final run = stepped(to: 180); + expect(run.rewind.step, 180, reason: 'every paused step is on tape'); + expect(run.toy.state, freshTo(180)); + + expect(run.timeline.releaseAtStep(150), isTrue); + expect(run.toy.state, freshTo(150)); + }); + + test('takes the keyframe that falls due on a paused step', () { + // Mutation: drop the keyframe from `stepOnce` — the rewind to 170 + // plays from the keyframe at 60, 110 steps, not 50. + final run = stepped(to: 180); + final point = run.rewind.rewindTo(170)!; + expect(point.replayed, 50, reason: 'the keyframe at 120 is held'); + expect(run.rewind.keyframesAfter(60).keys, [120]); + }); + + test("writes to every one of the loop's recorders, not only the " + "buffer's", () { + final demo = InputTapeRecorder(seed: 11); + final recorders = []; + final run = stepped(to: 130, recorders: recorders); + // An empty list handed over, so the paused steps went to no recorder + // at all — the list is used as given, not added to. + expect(run.rewind.step, 120); + expect(demo.tape.steps, 0); + + recorders + ..add(run.rewind.recorder) + ..add(demo); + _play(run.input, 130); + run.timeline.stepOnce(); + expect(run.rewind.step, 121); + expect(demo.tape.steps, 1, reason: 'a recorder added later is written'); + }); + + test('a branch says where it went and how far back that was', () { + final heard = <(int, int)>[]; + final run = stepped( + to: 180, + onBranched: (step, {required stepsAgo}) => heard.add((step, stepsAgo)), + ); + run.timeline.scrubTo(160, capture: run.toy.save); + run.timeline.branchHere(); + run.timeline.releaseAtStep(140); + // Mutation: count from after the cut — both are reported as 0. + expect(heard, <(int, int)>[(160, 20), (140, 20)]); + }); + }); + group('releaseAt', () { test('rewinding three seconds back and releasing lands the live state ' 'exactly where a fresh replay to that step would', () { From 43810850806f3441de7cfae5f0079b1f8a9628ae Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Sat, 3 Oct 2026 19:54:35 +0300 Subject: [PATCH 3/4] feat: Wire the games' timelines to their loop recorders and demos The platformer, crypt and racer hand RunTimeline their GameLoop.recorders and a capture, so a paused step lands on the rewind and demo tapes and keyframes. The platformer also checkpoints a paused step into its demo and cuts the demo on a branch; a branch back past the demo's start begins it again from now with the level on screen at step zero. --- apps/flutter3d_demo_dungeon/lib/main.dart | 3 + apps/flutter3d_demo_platformer/lib/main.dart | 29 ++++++++ .../test/demo_level_swap_test.dart | 69 ++++++++++++++++++- apps/flutter3d_demo_racing/lib/main.dart | 3 + 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/flutter3d_demo_dungeon/lib/main.dart b/apps/flutter3d_demo_dungeon/lib/main.dart index 59d598c70..8d36c8063 100644 --- a/apps/flutter3d_demo_dungeon/lib/main.dart +++ b/apps/flutter3d_demo_dungeon/lib/main.dart @@ -719,6 +719,9 @@ class _GameScreenState extends State input: _input, stepSim: (double dt) => _sim?.step(dt), restore: (Snapshot snapshot) => _sim?.restore(snapshot), + // A paused step goes on the loop's tapes and keyframes like a loop step. + recorders: _loop.recorders, + capture: () => _sim?.save() ?? const Snapshot({}), ); /// `rp-06`: how long each step of `sim.step` cost, read back over the same diff --git a/apps/flutter3d_demo_platformer/lib/main.dart b/apps/flutter3d_demo_platformer/lib/main.dart index 96bef8e31..6b27a31f1 100644 --- a/apps/flutter3d_demo_platformer/lib/main.dart +++ b/apps/flutter3d_demo_platformer/lib/main.dart @@ -159,11 +159,23 @@ class _GameScreenState extends State /// `rp-02`'s door onto this run, over the VM service. Reads `_sim` fresh on /// every call rather than capturing it, since which simulation that getter /// answers changes every time a level does. + /// + /// A step taken while paused is the run's like any other: written to the + /// loop's recorders (the buffer's and the demo's), keyframed, and + /// checkpointed into the demo. A branch cuts the demo's tape with the + /// buffer's ([_recordBranch]). late final RunTimeline _timeline = RunTimeline( rewind: _rewind, input: _input, stepSim: (double dt) => _sim?.step(dt), restore: (Snapshot snapshot) => _sim?.restore(snapshot), + recorders: _loop.recorders, + capture: _present, + onStepped: () { + final sim = _sim; + if (sim != null) _demo?.observe(sim.save); + }, + onBranched: _recordBranch, ); /// `rp-04`'s "send this run", called remotely rather than from a button @@ -404,6 +416,23 @@ class _GameScreenState extends State _demo?.levelSwapped(next, stepsAgo: 0); } + /// A branch through [_timeline] — a release on the scrubber, or "branch + /// here" — cuts the demo's tape where it cut the buffer's, so the + /// `.f3drun` holds the run that went on and not the future it left. + /// + /// A branch back before this demo began starts it again from now, in the + /// level it was loaded as and with the level on screen swapped in at its + /// first step, as [_recordSwap] does. + void _recordBranch(int step, {required int stepsAgo}) { + final demo = _demo; + final sim = _sim; + if (demo == null || sim == null) return; + if (demo.branched(stepsAgo: stepsAgo)) return; + final swaps = demo.levelSwaps; + _record(asset: demo.level, levelHash: demo.levelHash, start: sim.save()); + if (swaps.isNotEmpty) _demo?.levelSwapped(swaps.last.level, stepsAgo: 0); + } + /// Writes the run down when it ends, either way. /// /// Either way, because a death is the run somebody wants to send: "it threw diff --git a/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart b/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart index f718f3197..a2ff00e19 100644 --- a/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart +++ b/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart @@ -75,9 +75,10 @@ void _live( DemoRecording demo, { required int from, required int to, + void Function(InputState input, int step) play = _play, }) { for (var step = from; step < to; step++) { - _play(input, step); + play(input, step); rewind.recorder.record(input); demo.recorder.record(input); input.beginStep(); @@ -200,6 +201,72 @@ void main() { expect(parted.divergence!.step, greaterThan(file.levelSwaps.single.step)); }); + test('a run paused, stepped, branched back and played on, saved and ' + 'replayed, arrives where the branch went', () async { + const paused = 200; + const stepped = 212; + const branch = 170; + const end = 300; + final input = InputState(); + final run = _game(input); + await run.begin(); + final start = run.level!.sim.save(); + final demo = DemoRecording( + level: _first, + levelHash: run.level!.loaded.level.digestHex, + start: start, + seed: start.data.integer('random'), + ); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + // `main.dart`'s wiring: the loop's recorders, the keyframe, the + // checkpoint after a paused step, and the branch into the demo. + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (double dt) => run.level!.sim.step(dt), + restore: (Snapshot snapshot) => run.level!.sim.restore(snapshot), + recorders: [rewind.recorder, demo.recorder], + capture: () => run.level!.sim.save(), + onStepped: () => demo.observe(run.level!.sim.save), + onBranched: (int step, {required int stepsAgo}) => + expect(demo.branched(stepsAgo: stepsAgo), isTrue), + ); + + _live(run, input, rewind, demo, from: 0, to: paused); + timeline.pause(); + for (var step = paused; step < stepped; step++) { + _play(input, step); + timeline.stepOnce(); + } + expect(timeline.releaseAtStep(branch), isTrue); + // The branch stands still where the first run ran on. + _live( + run, + input, + rewind, + demo, + from: branch, + to: end, + play: (InputState input, int step) => + input.release(GameAction.moveForward), + ); + final arrived = _bytes(run.level!.sim.save()); + + final sent = jsonEncode(demo.demo(buildStamp: 'test-build').toJson()); + final file = Demo.fromJson(jsonDecode(sent) as Map); + // Mutation: make `DemoRecording.branched` a no-op — the file holds the + // 42 steps of the old future too, and replays somewhere else. + expect(file.steps, end); + + final replayInput = InputState(); + final replayRun = _game(replayInput); + await replayRun.begin(); + final replayed = await replayRun.replay(file); + expect(replayed.steps, end); + expect(replayed.divergence, isNull); + expect(_bytes(replayRun.level!.sim.save()), arrived); + }); + test('a demo recorded in another level is refused before a step', () async { final run = _game(InputState()); await run.begin(); diff --git a/apps/flutter3d_demo_racing/lib/main.dart b/apps/flutter3d_demo_racing/lib/main.dart index 220ebe416..33a8808f5 100644 --- a/apps/flutter3d_demo_racing/lib/main.dart +++ b/apps/flutter3d_demo_racing/lib/main.dart @@ -395,6 +395,9 @@ class _RaceScreenState extends State input: _input, stepSim: (double dt) => _simulation?.step(dt), restore: (Snapshot snapshot) => _simulation?.restore(snapshot), + // A paused step goes on the loop's tapes and keyframes like a loop step. + recorders: _loop.recorders, + capture: () => _simulation?.save() ?? const Snapshot({}), ); /// `rp-04`'s "send this run", called remotely rather than from a button From ea509a994eb2fdcfe1f3a83569a3f654332bd23d Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Sat, 3 Oct 2026 19:54:35 +0300 Subject: [PATCH 4/4] docs: Describe paused-step recording and demo branching in the roadmap N4 and HR3 move the stepOnce and branch items from Left to Done and say what stays: a paused step still runs only the simulation step, and the crypt and racer demos do not hear branches. Test counts updated. --- ARCHITECTURE.md | 2 +- README.md | 2 +- site/content/quickstart.md | 2 +- site/content/reference/testing.md | 12 +++++----- tasks/0.9-engine-roadmap.md | 40 ++++++++++++++++++++++--------- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2627251b2..04b8aaaeb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2691,7 +2691,7 @@ storage. |---|---| | Style | `dart format` | | Analysis | `flutter analyze` clean across the workspace, no warnings | -| Unit tests | **11340 tests** across 43 packages and 10 applications | +| Unit tests | **11348 tests** across 43 packages and 10 applications | | Structure rules | 35, `dart run tool/structure.dart`, the first CI step | | CI | GitHub Actions over `tool/ci.sh`, on `ubuntu-latest`, with no graphics card | diff --git a/README.md b/README.md index d468a8dd9..984839b82 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Or one package at a time: (cd packages/flutter3d_physics && dart test) # plain Dart, no Flutter needed ``` -There are 11340 tests across forty-three packages and nine applications. The +There are 11348 tests across forty-three packages and nine applications. The only ones that need a GPU are the Impeller half of the golden set. The other half is rendered by the software backend, which is what makes 90 scenes checkable in a headless run. diff --git a/site/content/quickstart.md b/site/content/quickstart.md index 9dac63652..b6d5de425 100644 --- a/site/content/quickstart.md +++ b/site/content/quickstart.md @@ -91,7 +91,7 @@ tool/ci.sh # shaders, analyze, every test (cd packages/flutter3d_physics && dart test) # plain Dart, no Flutter needed ``` -There are 11340 tests across 43 packages and twelve applications, and only about thirty need a GPU: the Impeller half of the golden set. The other half renders through the software backend, so ninety scenes stay checkable in a headless run. +There are 11348 tests across 43 packages and twelve applications, and only about thirty need a GPU: the Impeller half of the golden set. The other half renders through the software backend, so ninety scenes stay checkable in a headless run. ## Your own application diff --git a/site/content/reference/testing.md b/site/content/reference/testing.md index d89d101c1..a9f105201 100644 --- a/site/content/reference/testing.md +++ b/site/content/reference/testing.md @@ -1,17 +1,17 @@ --- -description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 11340 tests need a GPU. +description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 11348 tests need a GPU. --- # Testing -11340 tests across 43 packages and nine applications, counted the same way the `the document says how many tests there are` rule does: a scan of every `test(`/`testWidgets(` call. The rule holds `ARCHITECTURE.md` §13, the README and this page to the answer. The README went on saying 1242 across thirteen packages for as long as nothing compared it with anything. About thirty need a GPU; the [architecture](/core/architecture/) is what keeps the number that low. +11348 tests across 43 packages and nine applications, counted the same way the `the document says how many tests there are` rule does: a scan of every `test(`/`testWidgets(` call. The rule holds `ARCHITECTURE.md` §13, the README and this page to the answer. The README went on saying 1242 across thirteen packages for as long as nothing compared it with anything. About thirty need a GPU; the [architecture](/core/architecture/) is what keeps the number that low. | Package | Tests | | Package | Tests | |---|---|---|---|---| | `flutter3d` | 1659 | | | | | | | | `flutter3d_mesh` | 599 | | | | | `apps/flutter3d_modeler` | 1750 | -| `flutter3d_sim` | 691 | | `pad_input` | 67 | +| `flutter3d_sim` | 692 | | `pad_input` | 67 | | `flutter3d_lab` | 15 | | `flutter3d_core` | 771 | | `flutter3d_lti` | 26 | | `apps/flutter3d_lab_pendulum` | 7 | | | | | `apps/flutter3d_lab_incident` | 13 | @@ -19,7 +19,7 @@ description: Four independent golden sets, mutation-checking every new test, det | | | | `flutter3d_audio` | 4 | | `flutter3d_game_racing` | 223 | | `flutter3d_webgl` | 62 | | `flutter3d_game_platformer` | 221 | | `flutter3d_hardware` | 70 | -| `apps/flutter3d_demo_platformer` | 208 | | `flutter3d_impeller` | 64 | +| `apps/flutter3d_demo_platformer` | 209 | | `flutter3d_impeller` | 64 | | `flutter3d_cpu` | 380 | | `apps/flutter3d_demo_strategy` | 50 | | `apps/flutter3d_editor` | 234 | | `apps/flutter3d_demo_arcade` | 20 | | `apps/flutter3d_demo_racing` | 162 | | `pointer_lock` | 28 | @@ -28,7 +28,7 @@ description: Four independent golden sets, mutation-checking every new test, det | `apps/flutter3d_demo_river` | 54 | | `flutter3d_testing` | 45 | | `flutter3d_editor_core` | 141 | | `flutter3d_editor_widgets` | 91 | | `apps/flutter3d_demo_dungeon` | 115 | | `flutter3d_app` | 169 | -| `flutter3d_game` | 325 | | `flutter3d_shaders` | 5 | +| `flutter3d_game` | 331 | | `flutter3d_shaders` | 5 | | `flutter3d_particles` | 97 | | `flutter3d_stereo` | 50 | | `flutter3d_model_core` | 1172 | | `flutter3d_model_mcp` | 196 | | `flutter3d_editor_play` | 28 | | `flutter3d_net` | 15 | @@ -40,7 +40,7 @@ description: Four independent golden sets, mutation-checking every new test, det | `flame_flutter3d` | 140 | | `apps/flutter3d_showcase` | 88 | | `flame_multiplayer` | 9 | | `flame_multiplayer_dashwire` | 3 | -The rows sum to 11313 rather than 11340: the remaining 26 live in `packages/*/example/test`, which the count includes and this table does not, among them the two seeds a new project starts from, `packages/flutter3d_app/example` and `packages/flutter3d_game/example`. +The rows sum to 11321 rather than 11348: the remaining 26 live in `packages/*/example/test`, which the count includes and this table does not, among them the two seeds a new project starts from, `packages/flutter3d_app/example` and `packages/flutter3d_game/example`. `flutter3d_samples` is not in the table and has no `test/` at all: it is test data with two path constants over it, and other packages' decoder tests are what exercise it. `flutter3d_conformance` is missing for a different reason: it is invoked as a script harness rather than through `flutter test`, so it does not surface in a grep of `test(` calls either. See below for what that cost once. diff --git a/tasks/0.9-engine-roadmap.md b/tasks/0.9-engine-roadmap.md index 3dc054372..acdc341f4 100644 --- a/tasks/0.9-engine-roadmap.md +++ b/tasks/0.9-engine-roadmap.md @@ -199,12 +199,21 @@ an edit that took effect before the demo began, within a keyframe of the load, starts the demo again from now with the edit at step 0. `PlatformerRun.replay` builds every edited level before the first step and swaps them in through `replaceLevel`, as live. +A branch through the timeline cuts the demo too. `RunTimeline(onBranched:)` +hears the step `releaseAt` or `branchHere` cut to and how many steps back +that was; `DemoRecording.branched` truncates its tape there +(`InputTapeRecorder.truncate`), forgets the checkpoints after it and drops a +swap after it, keeping one at the step, whose level is still the one on +screen. The platformer wires it, and a branch back past the start of its +demo begins the demo again from now with the level on screen at step 0. *Tests:* in the platformer, a run edited at step 150 (swapped at the keyframe at 120), saved through JSON and replayed in a game of its own, arrives at the live run's bytes and keeps to every checkpoint, and the same file with the swap removed ends elsewhere (`demo_level_swap_test.dart`); the recording's step arithmetic, a second edit within a keyframe replacing the -first, and the refusals on a toy (`demo_recording_test.dart`); the format's +first, and the refusals on a toy (`demo_recording_test.dart`); a run paused, +stepped, branched back and played elsewhere, saved and replayed, arrives at +the live bytes with no divergence, in the platformer and on the toy; the format's round trip and refusals (`flutter3d_sim/test/demo_test.dart`). A save after a save goes as a patch. `LevelPatch.between` names a row of brushes, lights or entities by its place and the digest of what it was @@ -227,9 +236,10 @@ patch and the stale code over a real VM service. refuses one, since its keyframes do not know which level they belong to); a `.f3drun` with swaps verified by `flutter3d_sim_mcp`, which refuses one because it has no game to build the edited level with; a look-only edit is not written into the demo, so the -replay is right in what it simulates and shows the old lights and fog; a -branch through the timeline (`releaseAt`) still leaves the demo's tape on -the old future; the editor MCP driving the same path (its +replay is right in what it simulates and shows the old lights and fog; the +crypt and the racer keep their demos in fields of their own rather than a +`DemoRecording`, so a branch there still leaves the demo's tape on the old +future; the editor MCP driving the same path (its `PlaySession.sendSaved` still sends whole documents, and has no base to patch from); the platformer's `prepareEdit` still builds the whole edited level, so a patch saves the wire and the comparison, not the build. @@ -734,17 +744,25 @@ two `DigestTrace`s. Over the wire: `ext.flutter3d.timeline.scrubTo`, `returnToPresent`, `branchHere`, `tracks`, and `status` with the window. The editor's timeline panel has the scrubber, "Back to present", "Branch here" and the lanes, whose marks scrub to their step. +`stepOnce` at the present is a step the run keeps: it writes the input to +the loop's recorders (`RunTimeline(recorders:)`, the rewind buffer's alone +by default), takes the keyframe that falls due through `capture`, and runs +`onStepped` after, so a rewind or a branch past paused steps replays +through them. The platformer, the crypt and the racer hand the timeline +their `GameLoop.recorders`. *Tests:* a scrub back and forth lands on the state recorded before each -step; bisection finds a planted defect at its step and component in at most -twelve probes; the extensions end to end over a real VM service; the panel +step; paused steps at the present, then a release, land where a fresh run +to that step does, and the keyframe due on a paused step is held +(`run_timeline_test.dart`); bisection finds a planted defect at its step +and component in at most twelve probes; the extensions end to end over a real VM service; the panel against a fake client. *Left:* bisection is a library call; the two `.f3drun` files side by side in the editor and as MCP tools are P12. A scrub through a *loaded* `.f3drun`'s -whole length, beyond what the rewind buffer holds. `stepOnce` at the present -still steps without recording an entry or a keyframe (older than N4), so -the tape is short of the state by the steps taken that way, and a rewind or -branch afterwards replays without them. The cross-OS digest test §7 asks of the step does not apply yet: N4 -adds nothing that runs in the step. +whole length, beyond what the rewind buffer holds. A paused step runs only +the simulation's step (`stepSim`), not a game's whole loop step, so the +sounds, effects and events a game reads after its own step are not played +for it; they are drained on the next live step. The cross-OS digest test §7 +asks of the step does not apply yet: N4 adds nothing that runs in the step. **N5.** *Done:* `testReplay(path, start:, digestAt:, goldensAt:)` in `flutter3d_testing` registers a test that reads a `.f3drun`, builds the game on a software device and replays the tape into it. At each step of