Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/flutter3d_demo_dungeon/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,9 @@ class _GameScreenState extends State<GameScreen>
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(<String, Object?>{}),
);

/// `rp-06`: how long each step of `sim.step` cost, read back over the same
Expand Down
29 changes: 29 additions & 0 deletions apps/flutter3d_demo_platformer/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,23 @@ class _GameScreenState extends State<GameScreen>
/// `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
Expand Down Expand Up @@ -404,6 +416,23 @@ class _GameScreenState extends State<GameScreen>
_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
Expand Down
69 changes: 68 additions & 1 deletion apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: <InputTapeRecorder>[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<String, Object?>);
// 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();
Expand Down
3 changes: 3 additions & 0 deletions apps/flutter3d_demo_racing/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ class _RaceScreenState extends State<RaceScreen>
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(<String, Object?>{}),
);

/// `rp-04`'s "send this run", called remotely rather than from a button
Expand Down
10 changes: 10 additions & 0 deletions packages/flutter3d_game/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 32 additions & 1 deletion packages/flutter3d_game/lib/src/run/demo_recording.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<DemoLevelSwap> get levelSwaps =>
List<DemoLevelSwap>.unmodifiable(_swaps);

/// The run so far, as a file.
Demo demo({
required String buildStamp,
Expand All @@ -87,7 +118,7 @@ final class DemoRecording {
checkpoints: checkpoints,
platform: platform,
recordedBy: recordedBy,
levelSwaps: List<DemoLevelSwap>.unmodifiable(_swaps),
levelSwaps: levelSwaps,
);
}

Expand Down
53 changes: 51 additions & 2 deletions packages/flutter3d_game/lib/src/run/run_timeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,12 @@ final class RunTimeline {
required this.input,
required this.stepSim,
required this.restore,
List<InputTapeRecorder>? recorders,
this.capture,
this.onStepped,
this.onBranched,
this.stepSeconds = 1.0 / 60.0,
});
}) : recorders = recorders ?? <InputTapeRecorder>[rewind.recorder];

/// Where the recent past is kept, and what [releaseAt] cuts.
final RewindBuffer rewind;
Expand All @@ -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<InputTapeRecorder> 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;

Expand Down Expand Up @@ -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');
Expand All @@ -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());
}
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
Loading
Loading