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;