Skip to content

Replay host SPI

The Strux Mason

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 in
  • stageX(recordedPos), stageY(recordedPos), stageZ(recordedPos) — map a recorded position to the stage position it renders at

Viewer + moments

  • viewer() — the watching player
  • moments() — 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 api package. 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.