Replay host SPI¶

The strux replay viewer is a host. It plays back blocks, stress, and cascades — and nothing else. If you are building a game mode (for example, a Siege gamemode), you will want to show your data over the same replay: player ghosts, flag captures, a scoreboard.
This page shows how to do that without strux ever learning about players or flags.
The whole idea: you add your own layer on top of strux's block layer. Strux drives one clock; every
layer renders itself at the same moment in time. Your layer is a ReplayTrack.
Package:
dev.gesp.structural.replay.minecraft.api. Version:ReplayApi.VERSION(1.0.0).
The shape of it¶
strux block timeline ── the FIRST ReplayTrack (built in)
your player ghosts ── a ReplayTrack you provide
your flag overlay ── another ReplayTrack you provide
▲
│ one shared clock: onTick / onSeek / onSpeedChange / onPauseResume
│
ReplayViewer (the host)
You never call strux's render code. You implement callbacks; the host calls them.
Step 1 — register a provider¶
Grab the host's TrackRegistry once at startup (it is published to Bukkit's ServicesManager), and
register a TrackProvider.
TrackRegistry registry =
getServer().getServicesManager().load(TrackRegistry.class);
registry.register((sessionId, viewer) -> {
// Do you have a side file for this strux recording?
Path sidecar = mySidecarFor(sessionId);
if (sidecar == null) {
return null; // nothing for this session — strux plays fine on its own
}
return new MyGhostTrack(loadSidecar(sidecar));
});
When a player opens a recording, the host announces the strux sessionId. Each provider is asked
once. Return null if you have nothing — return a ReplayTrack if you do.
A strux replay with no side data plays fine. Side data with no strux anchor is meaningless and never loaded (there is no anchor to open it against).
Step 2 — implement the track¶
final class MyGhostTrack implements ReplayTrack {
private ReplayContext ctx;
@Override
public void onAttach(ReplayContext context) {
this.ctx = context;
// Do your UI contributions here (see Step 3).
}
@Override
public void onTick(long timelineMs) {
// A normal playback tick. Move your ghosts to where they were at timelineMs.
renderGhostsAt(timelineMs);
}
@Override
public void onSeek(long timelineMs) {
// A jump (seek / step / moment). Snap your ghosts to that instant.
renderGhostsAt(timelineMs);
}
@Override public void onSpeedChange(double speed) { /* optional */ }
@Override public void onPauseResume(boolean paused) { /* optional */ }
@Override
public void onDetach() {
// The viewer closed. Remove your display entities.
despawnGhosts();
}
}
Every method except onAttach is a default no-op — override only what you need. The host always
calls these on the main thread.
Step 3 — the ReplayContext¶
onAttach hands you a ReplayContext. That is the whole host surface you may touch. It stays valid
until onDetach.
Clock (read-only — the host drives it)¶
positionMs(),durationMs(),speed(),isPlaying()
Stage transform (render in the rebuilt stage, not the original world)¶
Recordings store the structure's real coordinates. The host rebuilds it at a stage origin. Your ghosts must use the same offset, or they will float off in space.
world()— the world the stage is instageX(recordedPos),stageY(recordedPos),stageZ(recordedPos)— map a recorded position to the stage position it renders at
Viewer + moments¶
viewer()— the watching playermoments()— the navigable moments (strux's plus any contributed), read-only
UI contribution points (keeps the UI-first promise)¶
| You call | It shows up as |
|---|---|
contributeMoment(moment) |
an entry in the 📋 moments menu + a marker on the boss bar |
contributeControlSection(section) |
your own toggle items in the ⚙ control menu |
contributeFilterChannel(name, on) |
a toggle in the control menu; you read the returned handle when rendering |
contributeInspectorHandler(handler) |
a link in the inspect chain — claim a right-click on your ghost |
Example — a "flag captured" moment and a "show ghosts" toggle:
@Override
public void onAttach(ReplayContext context) {
this.ctx = context;
context.contributeMoment(new Moment(
frameIndex, timelineMs, MomentKind.MARKER, "flag captured by Alice", 1.0, "alice"));
FilterChannelHandle showGhosts = context.contributeFilterChannel("show ghosts", true);
this.showGhosts = showGhosts; // read showGhosts.isOn() when rendering
context.contributeInspectorHandler(target -> {
if (isOneOfMyGhosts(target.raycast())) {
openMyPanel(target.player());
return true; // claimed — strux's block inspector is skipped
}
return false; // not mine — let the chain continue
});
}
The inspector chain offers each handler the InspectorTarget in registration order; the first to
return true wins. If none claims it, the host falls back to its own block inspector.
Step 4 — the recording side (same clock)¶
Your side track needs the same timebase as the strux anchor. Register a lifecycle listener on the
RecordingService (adapter-minecraft):
recordingService.addLifecycleListener(new RecordingService.RecordingLifecycleListener() {
@Override public void onRecordingStarted(String sessionId, long startTimeMs) {
myTrack = openSideTrack(sessionId, startTimeMs); // same id, same clock
}
@Override public void onRecordingStopped(String sessionId, long startTimeMs) {
myTrack.close();
}
});
startTimeMs is the shared zero point: stamp your samples against it so they line up with strux's
timelineMs on playback. recordingService.currentSessionStartMs() returns the same value on
demand. That is the entire recording-side coupling — strux never stores your data. For cross-domain
jump targets you can also drop strux markers via recordingService.mark(handle, name, meta).
Boundary rules (enforced)¶
- Dependency direction only: your plugin → this
apipackage. Nothing in strux references your types — the same hard rule as core vs adapters. - The surface stays small: clock + stage transform + UI registration. No feature here may need a
strux recording-format change beyond
metadata/MarkerEvent(both already exist). - Versioned:
ReplayApi.VERSION. Once you depend on it, changes follow semver — MINOR for backward-compatible additions, MAJOR for anything that breaks a type in this package.