registerTimelineExtensions function
- RunTimeline timeline, {
- StepTimeTrace? frameTimes,
- Map<
String, Object?> bugReport()?,
Puts a RunTimeline on the VM service, so a running game can be paused, stepped, scrubbed and branched from outside the process it is playing in — a level editor attached over the same channel Flutter DevTools already uses, rather than a simulation embedded inside the editor itself.
Why this, and not a simulation inside the editor. apps/flutter3d_editor
edits a Level and does not know a single EntityKind — it has no
registry, no genre, nothing to build a PlatformerSimulation or a
GameSimulation out of. Only a running game has that, already loaded,
already playing. ROADMAP.md's own plan for "the editors reach the
running game" is to reach it — hot reload and restart through the VM
service, a device already running the project — not to rebuild it a
second time inside a tool that was never going to carry every genre's
vocabulary. This is the timeline's own door onto that channel:
dart:developer's developer.registerExtension is the same mechanism
flutter_driver and DevTools use to reach into a running app, and a
RunTimeline registered here answers exactly the commands rp-02's
still-unbuilt panel would send — pause, step, preview a rewind, release
at a step — whether that panel ends up being a Flutter widget, an MCP
tool, or something else that speaks the VM service protocol.
What a caller on the other end sees
Seven extensions always, and up to two more depending on what the caller
hands over — each named ext.flutter3d.timeline.<verb>, callable the way
any package:vm_service client calls one —
service.callServiceExtension(isolateId, method: name, args: params) —
with string-keyed, string-valued parameters, because that is the one
shape the VM service protocol allows for them:
pause,resume,stepOnce— no parameters, mirror the timeline's own methods of the same name.preview—secondsAgo, a number as a string; returns{"found": true, "step": 118}or{"found": false}.releaseAtStep—step, an integer as a string; returns{"released": true}or{"released": false}when the buffer no longer reaches it.history— no parameters; returns{"commands": [...]}, one short string per TimelineCommand —"paused","resumed","stepped", or"branched:118"naming the step a branch landed at.status— no parameters; returns{"paused": true}— what a panel polls to draw its own pause/play button correctly on first connecting, rather than assuming the timeline was already running.frameTimes— registered only whenframeTimesis given; no parameters, returns thatStepTimeTrace's ownStepTimeTrace.toJson—rp-06's strip, read from wherever the caller is already timing its own step.bugReport— registered only whenbugReportis given; no parameters, returns whatever that callback hands back, or a VM service error if it throws —rp-04's "send this run", called remotely rather than from a button the game itself draws. What the callback returns is the caller's business; aDemo.toJson()is the obvious shape, and this does not require it.
Registered once per RunTimeline; registering the same timeline twice
throws, the same way developer.registerExtension itself refuses a
method name it has already seen.
Implementation
void registerTimelineExtensions(
RunTimeline timeline, {
StepTimeTrace? frameTimes,
Map<String, Object?> Function()? bugReport,
}) {
developer.registerExtension('ext.flutter3d.timeline.pause', (
method,
parameters,
) async {
timeline.pause();
return developer.ServiceExtensionResponse.result('{}');
});
developer.registerExtension('ext.flutter3d.timeline.resume', (
method,
parameters,
) async {
timeline.resume();
return developer.ServiceExtensionResponse.result('{}');
});
developer.registerExtension('ext.flutter3d.timeline.stepOnce', (
method,
parameters,
) async {
if (!timeline.isPaused) {
return developer.ServiceExtensionResponse.error(
developer.ServiceExtensionResponse.invalidParams,
'the timeline is not paused',
);
}
timeline.stepOnce();
return developer.ServiceExtensionResponse.result('{}');
});
developer.registerExtension('ext.flutter3d.timeline.preview', (
method,
parameters,
) async {
final secondsAgo = double.tryParse(parameters['secondsAgo'] ?? '');
if (secondsAgo == null) {
return developer.ServiceExtensionResponse.error(
developer.ServiceExtensionResponse.invalidParams,
'secondsAgo must be a number',
);
}
final point = timeline.preview(secondsAgo);
return developer.ServiceExtensionResponse.result(
jsonEncode(
point == null
? <String, Object?>{'found': false}
: <String, Object?>{'found': true, 'step': point.step},
),
);
});
developer.registerExtension('ext.flutter3d.timeline.releaseAtStep', (
method,
parameters,
) async {
final step = int.tryParse(parameters['step'] ?? '');
if (step == null) {
return developer.ServiceExtensionResponse.error(
developer.ServiceExtensionResponse.invalidParams,
'step must be an integer',
);
}
final released = timeline.releaseAtStep(step);
return developer.ServiceExtensionResponse.result(
jsonEncode(<String, Object?>{'released': released}),
);
});
developer.registerExtension('ext.flutter3d.timeline.history', (
method,
parameters,
) async {
return developer.ServiceExtensionResponse.result(
jsonEncode(<String, Object?>{
'commands': <String>[
for (final command in timeline.history) _describe(command),
],
}),
);
});
developer.registerExtension('ext.flutter3d.timeline.status', (
method,
parameters,
) async {
return developer.ServiceExtensionResponse.result(
jsonEncode(<String, Object?>{'paused': timeline.isPaused}),
);
});
if (frameTimes != null) {
developer.registerExtension('ext.flutter3d.timeline.frameTimes', (
method,
parameters,
) async {
return developer.ServiceExtensionResponse.result(
jsonEncode(frameTimes.toJson()),
);
});
}
if (bugReport != null) {
developer.registerExtension('ext.flutter3d.timeline.bugReport', (
method,
parameters,
) async {
try {
return developer.ServiceExtensionResponse.result(
jsonEncode(bugReport()),
);
} catch (error) {
return developer.ServiceExtensionResponse.error(
developer.ServiceExtensionResponse.extensionError,
'$error',
);
}
});
}
}