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/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 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', () { 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. 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