Skip to main content

subtr_actor/
clip.rs

1//! Replay clipping: trim a [`boxcars::Replay`] down to a small, self-contained
2//! window of frames that still processes through the full [`ReplayProcessor`]
3//! pipeline unchanged.
4//!
5//! # Why this works
6//!
7//! [`ReplayProcessor`] only consumes already-*decoded* [`boxcars::Frame`]s plus
8//! a few static tables (`net_version`, `objects`, `names`). It never touches the
9//! replay bitstream. A [`ReplayClip`] therefore needs only those tables and a
10//! list of frames.
11//!
12//! The catch is that the processor is fully incremental: the meaning of frame
13//! `N` depends on every actor spawned and every attribute set in frames `0..N`.
14//! Naively slicing out `frames[N..M]` would reference actors that were never
15//! created. To fix this, a clip begins with a **synthetic keyframe**: a single
16//! frame that re-spawns every actor that is alive at the start of the window and
17//! re-emits each of its current attributes (reconstructed from
18//! [`ActorStateModeler`]). When the clip is processed, that keyframe seeds the
19//! processor's world to exactly the state it had at the window boundary, after
20//! which the real frames replay normally.
21//!
22//! # Boundary artifacts and lead-in
23//!
24//! The synthetic keyframe reproduces *persistent* actor state perfectly, but the
25//! processor's per-frame updaters (touch detection, dodge detection, etc.) are
26//! delta-based and have no history before the keyframe. To keep the region you
27//! actually want to assert on clean, request a few frames of **lead-in** before
28//! it (see [`clip_replay_around`]). The differential tests quantify how much
29//! lead-in is needed for a faithful reproduction.
30//!
31//! [`ReplayProcessor`]: crate::processor::ReplayProcessor
32//! [`ActorStateModeler`]: crate::actor_state::ActorStateModeler
33
34use crate::actor_state::ActorStateModeler;
35use crate::{SubtrActorError, SubtrActorErrorVariant, SubtrActorResult};
36use serde::{Deserialize, Serialize};
37
38/// Current [`ReplayClip`] schema version. Bump on breaking layout changes.
39pub const CLIP_VERSION: u32 = 1;
40
41/// Where a [`ReplayClip`] came from in its source replay, for provenance and for
42/// mapping source frame indices back onto clip frame indices.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct ClipProvenance {
45    /// Index, in the source replay, of the first *real* frame included in the
46    /// clip (i.e. the frame immediately after the synthetic keyframe).
47    pub source_first_real_frame: usize,
48    /// Index, in the source replay, of the last real frame included (inclusive).
49    pub source_last_real_frame: usize,
50    /// Number of leading real frames included purely as warm-up before the
51    /// region of interest. `0` when the clip was taken by raw frame range.
52    pub lead_in_frames: usize,
53    /// Number of synthetic frames prepended (currently always `1` keyframe, or
54    /// `0` when the window starts at frame `0` and needs no seeding).
55    pub synthetic_frame_count: usize,
56}
57
58impl ClipProvenance {
59    /// Map an index in the *source* replay to the corresponding index in the
60    /// clip's `frames`, accounting for the prepended synthetic keyframe. Returns
61    /// `None` if the source frame is outside the clipped window.
62    pub fn clip_index_of(&self, source_frame: usize) -> Option<usize> {
63        if source_frame < self.source_first_real_frame || source_frame > self.source_last_real_frame
64        {
65            return None;
66        }
67        Some(self.synthetic_frame_count + (source_frame - self.source_first_real_frame))
68    }
69}
70
71/// A self-contained, serializable slice of a replay that can be processed by the
72/// full subtr-actor pipeline.
73///
74/// Reconstruct a [`boxcars::Replay`] with [`ReplayClip::to_replay`], then feed it
75/// to [`ReplayProcessor`](crate::processor::ReplayProcessor) like any other
76/// replay.
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct ReplayClip {
79    /// Schema version; see [`CLIP_VERSION`].
80    pub clip_version: u32,
81    /// `net_version` from the source replay (drives rigid-body normalization).
82    pub net_version: Option<i32>,
83    pub major_version: i32,
84    pub minor_version: i32,
85    /// `game_type` string from the source replay (metadata only).
86    pub game_type: String,
87    /// Object name table; an index into this is a `boxcars::ObjectId`.
88    pub objects: Vec<String>,
89    /// Name table, referenced by `name_id` on actors.
90    pub names: Vec<String>,
91    /// The synthetic keyframe (if any) followed by the real source frames.
92    pub frames: Vec<boxcars::Frame>,
93    /// Provenance / index mapping back to the source replay.
94    pub provenance: ClipProvenance,
95}
96
97impl ReplayClip {
98    /// Reconstruct a [`boxcars::Replay`] suitable for
99    /// [`ReplayProcessor`](crate::processor::ReplayProcessor). Header properties,
100    /// keyframes, net-cache and other bitstream-only tables are intentionally
101    /// left empty: the processor does not need them to walk decoded frames.
102    pub fn to_replay(&self) -> boxcars::Replay {
103        boxcars::Replay {
104            header_size: 0,
105            header_crc: 0,
106            major_version: self.major_version,
107            minor_version: self.minor_version,
108            net_version: self.net_version,
109            game_type: self.game_type.clone(),
110            properties: Vec::new(),
111            content_size: 0,
112            content_crc: 0,
113            network_frames: Some(boxcars::NetworkFrames {
114                frames: self.frames.clone(),
115            }),
116            levels: Vec::new(),
117            keyframes: Vec::new(),
118            debug_info: Vec::new(),
119            tick_marks: Vec::new(),
120            packages: Vec::new(),
121            objects: self.objects.clone(),
122            names: self.names.clone(),
123            class_indices: Vec::new(),
124            net_cache: Vec::new(),
125        }
126    }
127
128    /// Serialize to pretty JSON, the canonical fixture form.
129    pub fn to_json(&self) -> Result<String, serde_json::Error> {
130        serde_json::to_string_pretty(self)
131    }
132
133    /// Deserialize from JSON produced by [`ReplayClip::to_json`].
134    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
135        serde_json::from_str(s)
136    }
137}
138
139/// Whether an attribute records a transient *event* rather than persistent
140/// world state. The processor's event detectors fire on `UpdatedAttribute`s
141/// carrying these (boost pad pickups, demolishes, goal explosions), so
142/// re-emitting a stale one from the synthetic keyframe would manufacture a
143/// phantom event that never happened inside the clip window. They carry no
144/// state any detector reads back, so the keyframe simply omits them.
145fn attribute_is_transient_event(attribute: &boxcars::Attribute) -> bool {
146    matches!(
147        attribute,
148        boxcars::Attribute::Pickup(_)
149            | boxcars::Attribute::PickupNew(_)
150            | boxcars::Attribute::Demolish(_)
151            | boxcars::Attribute::DemolishFx(_)
152            | boxcars::Attribute::DemolishExtended(_)
153            | boxcars::Attribute::Explosion(_)
154            | boxcars::Attribute::ExtendedExplosion(_)
155            | boxcars::Attribute::StatEvent(_)
156    )
157}
158
159/// Build the synthetic keyframe that recreates the world modeled by `modeler`.
160///
161/// Emits one `NewActor` per live actor plus one `UpdatedAttribute` per current
162/// attribute (transient event-like attributes excepted; see
163/// [`attribute_is_transient_event`]). Output is sorted (actors by id,
164/// attributes by object id) so the resulting clip is deterministic and its JSON
165/// fixture is stable/diffable.
166fn synthesize_keyframe(modeler: &ActorStateModeler, time: f32) -> boxcars::Frame {
167    let mut new_actors: Vec<boxcars::NewActor> = Vec::with_capacity(modeler.actor_states.len());
168    let mut updated_actors: Vec<boxcars::UpdatedAttribute> = Vec::new();
169
170    let mut actor_ids: Vec<&boxcars::ActorId> = modeler.actor_states.keys().collect();
171    actor_ids.sort_by_key(|id| id.0);
172
173    for actor_id in actor_ids {
174        let state = &modeler.actor_states[actor_id];
175        new_actors.push(boxcars::NewActor {
176            actor_id: *actor_id,
177            name_id: state.name_id,
178            object_id: state.object_id,
179            // `initial_trajectory` is never read by the processor; position is
180            // carried by the re-emitted RigidBody/Location attributes below.
181            initial_trajectory: boxcars::Trajectory {
182                location: None,
183                rotation: None,
184            },
185        });
186
187        let mut object_ids: Vec<&boxcars::ObjectId> = state.attributes.keys().collect();
188        object_ids.sort_by_key(|id| id.0);
189        for object_id in object_ids {
190            let (attribute, _source_frame) = &state.attributes[object_id];
191            if attribute_is_transient_event(attribute) {
192                continue;
193            }
194            updated_actors.push(boxcars::UpdatedAttribute {
195                actor_id: *actor_id,
196                // `stream_id` is unused post-decode; mirror the object id.
197                stream_id: boxcars::StreamId(object_id.0),
198                object_id: *object_id,
199                attribute: attribute.clone(),
200            });
201        }
202    }
203
204    boxcars::Frame {
205        time,
206        delta: 0.0,
207        new_actors,
208        deleted_actors: Vec::new(),
209        updated_actors,
210    }
211}
212
213/// Extract a clip spanning the inclusive source frame range `[real_start, real_end]`.
214///
215/// A synthetic keyframe reproducing the world state as of the end of frame
216/// `real_start - 1` is prepended (unless `real_start == 0`). `lead_in_frames` is
217/// recorded in provenance for callers that built the range with warm-up padding;
218/// it does not affect which frames are included.
219pub fn clip_replay_range(
220    replay: &boxcars::Replay,
221    real_start: usize,
222    real_end: usize,
223    lead_in_frames: usize,
224) -> SubtrActorResult<ReplayClip> {
225    let source_frames = &replay
226        .network_frames
227        .as_ref()
228        .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::NoNetworkFrames))?
229        .frames;
230
231    if real_start > real_end || real_end >= source_frames.len() {
232        return SubtrActorError::new_result(SubtrActorErrorVariant::FrameIndexOutOfBounds);
233    }
234
235    // Seed an actor-state model up to (but not including) the window start.
236    let mut modeler = ActorStateModeler::new();
237    for (index, frame) in source_frames.iter().enumerate().take(real_start) {
238        modeler.process_frame(frame, index)?;
239    }
240
241    let mut frames = Vec::with_capacity(real_end - real_start + 2);
242    let synthetic_frame_count = if real_start > 0 {
243        // Time the keyframe just before the first real frame for continuity.
244        let keyframe_time = source_frames[real_start - 1].time;
245        frames.push(synthesize_keyframe(&modeler, keyframe_time));
246        1
247    } else {
248        0
249    };
250    frames.extend(source_frames[real_start..=real_end].iter().cloned());
251
252    Ok(ReplayClip {
253        clip_version: CLIP_VERSION,
254        net_version: replay.net_version,
255        major_version: replay.major_version,
256        minor_version: replay.minor_version,
257        game_type: replay.game_type.clone(),
258        objects: replay.objects.clone(),
259        names: replay.names.clone(),
260        frames,
261        provenance: ClipProvenance {
262            source_first_real_frame: real_start,
263            source_last_real_frame: real_end,
264            lead_in_frames,
265            synthetic_frame_count,
266        },
267    })
268}
269
270/// Extract a clip centered on a region of interest `[region_start, region_end]`,
271/// padded with `lead_in` warm-up frames before it and `tail` frames after.
272///
273/// This is the ergonomic entry point for tests: pick the frames around an event
274/// and get back a clip whose region of interest is preceded by real frames, so
275/// delta-based detectors are warmed up before the assertion window.
276pub fn clip_replay_around(
277    replay: &boxcars::Replay,
278    region_start: usize,
279    region_end: usize,
280    lead_in: usize,
281    tail: usize,
282) -> SubtrActorResult<ReplayClip> {
283    let frame_count = replay
284        .network_frames
285        .as_ref()
286        .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::NoNetworkFrames))?
287        .frames
288        .len();
289
290    let real_start = region_start.saturating_sub(lead_in);
291    let real_end = region_end
292        .saturating_add(tail)
293        .min(frame_count.saturating_sub(1));
294    let actual_lead_in = region_start - real_start;
295    clip_replay_range(replay, real_start, real_end, actual_lead_in)
296}
297
298/// Index of the first frame whose `time` is at or after `time` (the last frame
299/// if every frame is earlier).
300pub fn frame_index_at_time(replay: &boxcars::Replay, time: f32) -> SubtrActorResult<usize> {
301    let frames = &replay
302        .network_frames
303        .as_ref()
304        .ok_or_else(|| SubtrActorError::new(SubtrActorErrorVariant::NoNetworkFrames))?
305        .frames;
306    Ok(frames
307        .iter()
308        .position(|frame| frame.time >= time)
309        .unwrap_or(frames.len().saturating_sub(1)))
310}
311
312/// [`clip_replay_around`], but with the region of interest given in replay
313/// seconds instead of frame indices. Most event assertions are written against
314/// event times (which clips preserve from the source replay), so this is the
315/// usual entry point when migrating a full-replay test onto a clip.
316pub fn clip_replay_around_times(
317    replay: &boxcars::Replay,
318    region_start_time: f32,
319    region_end_time: f32,
320    lead_in: usize,
321    tail: usize,
322) -> SubtrActorResult<ReplayClip> {
323    let region_start = frame_index_at_time(replay, region_start_time)?;
324    let region_end = frame_index_at_time(replay, region_end_time)?;
325    clip_replay_around(replay, region_start, region_end, lead_in, tail)
326}
327
328#[cfg(test)]
329#[path = "clip_tests.rs"]
330mod tests;