subtr_actor/collector/replay_data.rs
1//! # Replay Data Collection Module
2//!
3//! This module provides comprehensive data structures and collection mechanisms
4//! for extracting and organizing Rocket League replay data. It offers a complete
5//! representation of ball, player, and game state information across all frames
6//! of a replay.
7//!
8//! The module is built around the [`ReplayDataCollector`] which implements the
9//! [`Collector`] trait, allowing it to process replay frames and extract
10//! detailed information about player actions, ball movement, and game state.
11//!
12//! # Key Components
13//!
14//! - [`ReplayData`] - The complete replay data structure containing all extracted information
15//! - [`FrameData`] - Frame-by-frame data including ball, player, and metadata information
16//! - [`PlayerFrame`] - Detailed player state including position, controls, and actions
17//! - [`BallFrame`] - Ball state including rigid body physics information
18//! - [`MetadataFrame`] - Game state metadata including time and score information
19//!
20//! # Example Usage
21//!
22//! ```rust
23//! use subtr_actor::collector::replay_data::ReplayDataCollector;
24//! use boxcars::ParserBuilder;
25//!
26//! let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
27//! let replay = ParserBuilder::new(&data).parse().unwrap();
28//!
29//! let collector = ReplayDataCollector::new();
30//! let replay_data = collector.get_replay_data(&replay).unwrap();
31//!
32//! // Access frame-by-frame data
33//! for metadata_frame in &replay_data.frame_data.metadata_frames {
34//! println!("Time: {:.2}s, Remaining: {}s",
35//! metadata_frame.time, metadata_frame.seconds_remaining);
36//! }
37//! ```
38
39use boxcars;
40use serde::Serialize;
41
42use crate::*;
43
44/// Represents the ball state for a single frame in a Rocket League replay.
45///
46/// The ball can either be in an empty state (when ball syncing is disabled or
47/// the rigid body is unavailable) or contain full physics data including
48/// position, rotation, and velocity information.
49///
50/// # Variants
51///
52/// - [`Empty`](BallFrame::Empty) - Indicates the ball is unavailable or ball syncing is disabled
53/// - [`Data`](BallFrame::Data) - Contains the ball's rigid body physics information
54#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
55#[ts(export)]
56pub enum BallFrame {
57 /// Empty frame indicating the ball is unavailable or ball syncing is disabled
58 Empty,
59 /// Frame containing the ball's rigid body physics data
60 Data {
61 /// The ball's rigid body containing position, rotation, and velocity information
62 #[ts(as = "crate::interop::ts_bindings::RigidBodyTs")]
63 rigid_body: boxcars::RigidBody,
64 },
65}
66
67impl BallFrame {
68 /// Creates a new [`BallFrame`] from a [`ReplayProcessor`] at the specified time.
69 ///
70 /// This method extracts the ball's state from the replay processor, handling
71 /// cases where ball syncing is disabled or the rigid body is unavailable.
72 ///
73 /// # Arguments
74 ///
75 /// * `processor` - The [`ReplayProcessor`] containing the replay data
76 /// * `current_time` - The time in seconds at which to extract the ball state
77 ///
78 /// # Returns
79 ///
80 /// Returns a [`BallFrame`] which will be [`Empty`](BallFrame::Empty) if:
81 /// - Ball syncing is disabled in the replay
82 /// - The ball's rigid body cannot be retrieved
83 ///
84 /// Otherwise returns [`Data`](BallFrame::Data) containing the ball's rigid body.
85 fn new_from_processor(processor: &dyn ProcessorView, current_time: f32) -> Self {
86 if processor.get_ignore_ball_syncing().unwrap_or(false) {
87 Self::Empty
88 } else {
89 match processor.get_interpolated_ball_rigid_body(current_time, 0.0) {
90 Ok(rigid_body) => Self::new_from_rigid_body(rigid_body),
91 _ => Self::Empty,
92 }
93 }
94 }
95
96 /// Creates a new [`BallFrame`] from a rigid body.
97 ///
98 /// # Arguments
99 ///
100 /// * `rigid_body` - The ball's rigid body containing physics information
101 ///
102 /// # Returns
103 ///
104 /// Returns [`Data`](BallFrame::Data) containing the rigid body even when the
105 /// ball is sleeping, so stationary kickoff frames still retain the ball's
106 /// position for downstream consumers such as the JS player.
107 fn new_from_rigid_body(rigid_body: boxcars::RigidBody) -> Self {
108 Self::Data { rigid_body }
109 }
110}
111
112/// Replay-driven continuous camera look state for a player at a single frame.
113///
114/// Captured from the player's `TAGame.CameraSettingsActor_TA` actor. Rocket
115/// League does not replicate the camera's world position, so this is the raw
116/// material a renderer uses to *reconstruct* the player's point of view rather
117/// than a literal camera transform. The discrete camera toggles (ball cam,
118/// behind-view) flip rarely and are carried in the coalesced
119/// [`PlayerCameraStateChange`] stream instead of on every frame.
120///
121/// Every field is optional: it is `None` when the replay does not replicate
122/// that attribute for the player (e.g. very old replays, or a player whose
123/// camera-settings actor has not appeared yet).
124#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, ts_rs::TS)]
125#[ts(export)]
126pub struct PlayerCameraFrame {
127 /// Raw camera pitch byte (0-255) as replicated; convert at display time.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub pitch: Option<u8>,
130 /// Raw camera yaw byte (0-255) as replicated; convert at display time.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub yaw: Option<u8>,
133}
134
135/// Replay-driven vehicle input/state for a player at a single frame.
136///
137/// Captured from the car's `TAGame.Vehicle_TA` actor and dodge component.
138/// These let a renderer drive accurate wheel steering/spin and flip direction
139/// instead of estimating them from position deltas. The rarely-flipping driving
140/// flag lives in the coalesced [`PlayerCameraStateChange`] stream instead.
141///
142/// Every field is optional: it is `None` when the replay does not replicate
143/// that attribute for the frame.
144#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, ts_rs::TS)]
145#[ts(export)]
146pub struct PlayerInputFrame {
147 /// Raw throttle byte (0-255, ~128 neutral); convert at display time.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub throttle: Option<u8>,
150 /// Raw steer byte (0-255, ~128 centered); convert at display time.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub steer: Option<u8>,
153 /// Impulse vector `(x, y, z)` in raw replay units of the most recent
154 /// dodge. Meaningful while [`PlayerFrame::Data::dodge_active`] is set.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub dodge_impulse: Option<(f32, f32, f32)>,
157 /// Torque vector `(x, y, z)` in raw replay units of the most recent dodge.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub dodge_torque: Option<(f32, f32, f32)>,
160}
161
162/// Represents a player's state for a single frame in a Rocket League replay.
163///
164/// Contains comprehensive information about a player's position, movement,
165/// and control inputs during a specific frame of the replay.
166///
167/// # Variants
168///
169/// - [`Empty`](PlayerFrame::Empty) - Indicates the player state is unavailable
170/// - [`Data`](PlayerFrame::Data) - Contains the player's complete state information
171#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
172#[ts(export)]
173pub enum PlayerFrame {
174 /// Empty frame indicating the player state is unavailable
175 Empty,
176 /// Frame containing the player's complete state data
177 Data {
178 /// The player's rigid body containing position, rotation, and velocity information
179 #[ts(as = "crate::interop::ts_bindings::RigidBodyTs")]
180 rigid_body: boxcars::RigidBody,
181 /// The player's current boost amount in raw replay units (0.0 to 255.0)
182 boost_amount: f32,
183 /// Whether the player is actively using boost
184 boost_active: bool,
185 /// Whether the player is actively powersliding / holding handbrake
186 powerslide_active: bool,
187 /// Whether the player is actively jumping
188 jump_active: bool,
189 /// Whether the player is performing a double jump
190 double_jump_active: bool,
191 /// Whether the player is performing a dodge maneuver
192 dodge_active: bool,
193 /// The player's name as it appears in the replay
194 player_name: Option<String>,
195 /// The team the player belongs to (0 or 1)
196 team: Option<i32>,
197 /// Whether the player is on team 0 (blue team typically)
198 is_team_0: Option<bool>,
199 /// Replay-driven camera state (ball cam, look direction) for the player
200 camera: PlayerCameraFrame,
201 /// Replay-driven vehicle inputs (throttle, steer, dodge vectors)
202 input: PlayerInputFrame,
203 },
204}
205
206impl PlayerFrame {
207 /// Creates a new [`PlayerFrame`] from a [`ReplayProcessor`] for a specific player at the specified time.
208 ///
209 /// This method extracts comprehensive player state information from the replay processor,
210 /// including position, control inputs, and team information.
211 ///
212 /// # Arguments
213 ///
214 /// * `processor` - The [`ReplayProcessor`] containing the replay data
215 /// * `player_id` - The unique identifier for the player
216 /// * `current_time` - The time in seconds at which to extract the player state
217 ///
218 /// # Returns
219 ///
220 /// Returns a [`SubtrActorResult`] containing a [`PlayerFrame::Data`] value
221 /// with the player's complete state information.
222 ///
223 /// # Errors
224 ///
225 /// Returns a [`SubtrActorError`] if:
226 /// - The player's rigid body cannot be retrieved
227 fn new_from_processor(
228 processor: &dyn ProcessorView,
229 player_id: &PlayerId,
230 current_time: f32,
231 ) -> SubtrActorResult<Self> {
232 let rigid_body =
233 processor.get_interpolated_player_rigid_body(player_id, current_time, 0.0)?;
234
235 let boost_amount = processor.get_player_boost_level(player_id).unwrap_or(0.0);
236 let boost_active = processor.get_boost_active(player_id).unwrap_or(0) % 2 == 1;
237 let powerslide_active = processor.get_powerslide_active(player_id).unwrap_or(false);
238 let jump_active = processor.get_jump_active(player_id).unwrap_or(0) % 2 == 1;
239 let double_jump_active = processor.get_double_jump_active(player_id).unwrap_or(0) % 2 == 1;
240 let dodge_active = processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1;
241
242 // Replay-driven continuous camera/vehicle state. Each read is optional:
243 // older replays and frames without the attribute simply leave it `None`
244 // so consumers can fall back to a synthesized value. Discrete toggles
245 // (ball cam, behind-view, driving) are emitted as coalesced
246 // `PlayerCameraStateChange`s rather than stored on every frame.
247 let camera = PlayerCameraFrame {
248 pitch: processor.get_camera_pitch(player_id).ok(),
249 yaw: processor.get_camera_yaw(player_id).ok(),
250 };
251 let input = PlayerInputFrame {
252 throttle: processor.get_throttle(player_id).ok(),
253 steer: processor.get_steer(player_id).ok(),
254 dodge_impulse: processor.get_dodge_impulse(player_id).ok(),
255 dodge_torque: processor.get_dodge_torque(player_id).ok(),
256 };
257
258 // Extract player identity information
259 let player_name = processor.get_player_name(player_id).ok();
260 let team = processor
261 .get_player_team_key(player_id)
262 .ok()
263 .and_then(|team_key| team_key.parse::<i32>().ok());
264 let is_team_0 = processor.get_player_is_team_0(player_id).ok();
265
266 Ok(Self::from_data(
267 rigid_body,
268 boost_amount,
269 boost_active,
270 powerslide_active,
271 jump_active,
272 double_jump_active,
273 dodge_active,
274 player_name,
275 team,
276 is_team_0,
277 camera,
278 input,
279 ))
280 }
281
282 /// Creates a [`PlayerFrame`] from the provided data components.
283 ///
284 /// # Arguments
285 ///
286 /// * `rigid_body` - The player's rigid body physics information
287 /// * `boost_amount` - The player's current boost level in raw replay units (0.0 to 255.0)
288 /// * `boost_active` - Whether the player is actively using boost
289 /// * `powerslide_active` - Whether the player is actively powersliding
290 /// * `jump_active` - Whether the player is actively jumping
291 /// * `double_jump_active` - Whether the player is performing a double jump
292 /// * `dodge_active` - Whether the player is performing a dodge maneuver
293 /// * `player_name` - The player's name, if available
294 /// * `team` - The player's team number, if available
295 /// * `is_team_0` - Whether the player is on team 0, if available
296 /// * `camera` - Replay-driven camera state for the player
297 /// * `input` - Replay-driven vehicle input/state for the player
298 ///
299 /// # Returns
300 ///
301 /// Returns [`Data`](PlayerFrame::Data) with all provided information, even
302 /// when the rigid body is sleeping, so stationary kickoff and reset frames
303 /// still retain the player's position for downstream consumers such as the
304 /// JS player.
305 #[allow(clippy::too_many_arguments)]
306 fn from_data(
307 rigid_body: boxcars::RigidBody,
308 boost_amount: f32,
309 boost_active: bool,
310 powerslide_active: bool,
311 jump_active: bool,
312 double_jump_active: bool,
313 dodge_active: bool,
314 player_name: Option<String>,
315 team: Option<i32>,
316 is_team_0: Option<bool>,
317 camera: PlayerCameraFrame,
318 input: PlayerInputFrame,
319 ) -> Self {
320 Self::Data {
321 rigid_body,
322 boost_amount,
323 boost_active,
324 powerslide_active,
325 jump_active,
326 double_jump_active,
327 dodge_active,
328 player_name,
329 team,
330 is_team_0,
331 camera,
332 input,
333 }
334 }
335}
336
337/// Contains all frame data for a single player throughout the replay.
338///
339/// This structure holds a chronological sequence of [`PlayerFrame`] instances
340/// representing the player's state at each processed frame of the replay.
341///
342/// # Fields
343///
344/// * `frames` - A vector of [`PlayerFrame`] instances in chronological order
345#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
346#[ts(export)]
347pub struct PlayerData {
348 /// Vector of player frames in chronological order
349 frames: Vec<PlayerFrame>,
350}
351
352impl PlayerData {
353 /// Creates a new empty [`PlayerData`] instance.
354 ///
355 /// # Returns
356 ///
357 /// Returns a new [`PlayerData`] with an empty frames vector.
358 fn new() -> Self {
359 Self { frames: Vec::new() }
360 }
361
362 /// Adds a player frame at the specified frame index.
363 ///
364 /// If the frame index is beyond the current length of the frames vector,
365 /// empty frames will be inserted to fill the gap before adding the new frame.
366 ///
367 /// # Arguments
368 ///
369 /// * `frame_index` - The index at which to insert the frame
370 /// * `frame` - The [`PlayerFrame`] to add
371 fn add_frame(&mut self, frame_index: usize, frame: PlayerFrame) {
372 let empty_frames_to_add = frame_index - self.frames.len();
373 if empty_frames_to_add > 0 {
374 for _ in 0..empty_frames_to_add {
375 self.frames.push(PlayerFrame::Empty)
376 }
377 }
378 self.frames.push(frame)
379 }
380
381 /// Returns a reference to the frames vector.
382 ///
383 /// # Returns
384 ///
385 /// Returns a reference to the vector of [`PlayerFrame`] instances.
386 pub fn frames(&self) -> &Vec<PlayerFrame> {
387 &self.frames
388 }
389
390 /// Returns the number of frames in this player's data.
391 ///
392 /// # Returns
393 ///
394 /// Returns the total number of frames stored for this player.
395 pub fn frame_count(&self) -> usize {
396 self.frames.len()
397 }
398}
399
400/// Contains all frame data for the ball throughout the replay.
401///
402/// This structure holds a chronological sequence of [`BallFrame`] instances
403/// representing the ball's state at each processed frame of the replay.
404///
405/// # Fields
406///
407/// * `frames` - A vector of [`BallFrame`] instances in chronological order
408#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
409#[ts(export)]
410pub struct BallData {
411 /// Vector of ball frames in chronological order
412 frames: Vec<BallFrame>,
413}
414
415impl BallData {
416 /// Creates a new empty [`BallData`] instance.
417 ///
418 /// # Returns
419 ///
420 /// Returns a new [`BallData`] with an empty frames vector.
421 fn new() -> Self {
422 Self { frames: Vec::new() }
423 }
424
425 /// Adds a ball frame at the specified frame index.
426 ///
427 /// If the frame index is beyond the current length of the frames vector,
428 /// empty frames will be inserted to fill the gap before adding the new frame.
429 ///
430 /// # Arguments
431 ///
432 /// * `frame_index` - The index at which to insert the frame
433 /// * `frame` - The [`BallFrame`] to add
434 fn add_frame(&mut self, frame_index: usize, frame: BallFrame) {
435 let empty_frames_to_add = frame_index - self.frames.len();
436 if empty_frames_to_add > 0 {
437 for _ in 0..empty_frames_to_add {
438 self.frames.push(BallFrame::Empty)
439 }
440 }
441 self.frames.push(frame)
442 }
443
444 /// Returns a reference to the frames vector.
445 ///
446 /// # Returns
447 ///
448 /// Returns a reference to the vector of [`BallFrame`] instances.
449 pub fn frames(&self) -> &Vec<BallFrame> {
450 &self.frames
451 }
452
453 /// Returns the number of frames in the ball data.
454 ///
455 /// # Returns
456 ///
457 /// Returns the total number of frames stored for the ball.
458 pub fn frame_count(&self) -> usize {
459 self.frames.len()
460 }
461}
462
463/// Represents game metadata for a single frame in a Rocket League replay.
464///
465/// Contains timing information and game state data that applies to the entire
466/// game at a specific point in time.
467///
468/// # Fields
469///
470/// * `time` - The current time in seconds since the start of the replay
471/// * `seconds_remaining` - The number of seconds remaining in the current game period
472/// * `replicated_game_state_name` - The game state enum value (indicates countdown, playing, goal, etc.)
473/// * `replicated_game_state_time_remaining` - The kickoff countdown timer, usually 3 to 0
474#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
475#[ts(export)]
476pub struct MetadataFrame {
477 /// The current time in seconds since the start of the replay
478 pub time: f32,
479 /// The number of seconds remaining in the current game period
480 pub seconds_remaining: i32,
481 /// The game state enum value (indicates countdown, playing, goal scored, etc.)
482 pub replicated_game_state_name: i32,
483 /// The kickoff countdown timer exposed by the replay metadata actor.
484 pub replicated_game_state_time_remaining: i32,
485}
486
487impl MetadataFrame {
488 /// Creates a new [`MetadataFrame`] from a [`ReplayProcessor`] at the specified time.
489 ///
490 /// # Arguments
491 ///
492 /// * `processor` - The [`ReplayProcessor`] containing the replay data
493 /// * `time` - The current time in seconds since the start of the replay
494 ///
495 /// # Returns
496 ///
497 /// Returns a [`SubtrActorResult`] containing a [`MetadataFrame`] with the
498 /// current time and remaining seconds extracted from the processor.
499 ///
500 /// # Errors
501 ///
502 /// Missing replay metadata fields default to 0 so frame export can continue
503 /// for replays whose metadata actor does not carry every optional property.
504 fn new_from_processor(processor: &dyn ProcessorView, time: f32) -> SubtrActorResult<Self> {
505 Ok(Self::new(
506 time,
507 metadata_i32_or_default(processor.get_seconds_remaining()),
508 metadata_i32_or_default(processor.get_replicated_state_name()),
509 metadata_i32_or_default(processor.get_replicated_game_state_time_remaining()),
510 ))
511 }
512
513 /// Creates a new [`MetadataFrame`] with the specified time, seconds remaining, game state,
514 /// and kickoff countdown value.
515 ///
516 /// # Arguments
517 ///
518 /// * `time` - The current time in seconds since the start of the replay
519 /// * `seconds_remaining` - The number of seconds remaining in the current game period
520 /// * `replicated_game_state_name` - The game state enum value
521 /// * `replicated_game_state_time_remaining` - The kickoff countdown timer
522 ///
523 /// # Returns
524 ///
525 /// Returns a new [`MetadataFrame`] with the provided values.
526 fn new(
527 time: f32,
528 seconds_remaining: i32,
529 replicated_game_state_name: i32,
530 replicated_game_state_time_remaining: i32,
531 ) -> Self {
532 MetadataFrame {
533 time,
534 seconds_remaining,
535 replicated_game_state_name,
536 replicated_game_state_time_remaining,
537 }
538 }
539}
540
541fn metadata_i32_or_default(value: SubtrActorResult<i32>) -> i32 {
542 value.unwrap_or(0)
543}
544
545#[cfg(test)]
546#[path = "replay_data_tests.rs"]
547mod replay_data_tests;
548
549/// Contains all frame-by-frame data for a Rocket League replay.
550///
551/// This structure organizes ball data, player data, and metadata for each
552/// frame of the replay, providing a complete picture of the game state
553/// throughout the match.
554///
555/// # Fields
556///
557/// * `ball_data` - All ball state information across all frames
558/// * `players` - Player data for each player, indexed by [`PlayerId`]
559/// * `metadata_frames` - Game metadata for each frame including timing information
560#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
561#[ts(export)]
562pub struct FrameData {
563 /// All ball state information across all frames
564 pub ball_data: BallData,
565 /// Player data for each player, indexed by PlayerId
566 #[ts(as = "Vec<(crate::interop::ts_bindings::RemoteIdTs, PlayerData)>")]
567 pub players: Vec<(PlayerId, PlayerData)>,
568 /// Game metadata for each frame including timing information
569 pub metadata_frames: Vec<MetadataFrame>,
570}
571
572/// Complete replay data structure containing all extracted information from a Rocket League replay.
573///
574/// This is the top-level structure that contains all processed replay data including
575/// frame-by-frame information, replay metadata, and special events like demolitions.
576///
577/// # Fields
578///
579/// * `frame_data` - All frame-by-frame data including ball, player, and metadata information
580/// * `meta` - Replay metadata including player information, game settings, and statistics
581/// * `demolish_infos` - Information about all demolition events that occurred during the replay
582/// * `boost_pad_events` - Exact boost pad pickup/availability events detected while processing
583/// * `boost_pads` - Resolved standard boost pad layout annotated with replay pad ids when known
584/// * `touch_events` - Replay-authored team touch markers; player attribution is derived by stats
585/// * `dodge_refreshed_events` - Exact counter-derived dodge refresh events from the replay
586/// * `player_stat_events` - Exact shot/save/assist counter increment events
587/// * `goal_events` - Exact goal explosion events with scorer and cumulative score when available
588/// * `replay_tick_marks` - Replay-authored timeline tick marks/bookmarks
589///
590/// # Example
591///
592/// ```rust
593/// use subtr_actor::collector::replay_data::ReplayDataCollector;
594/// use boxcars::ParserBuilder;
595///
596/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
597/// let replay = ParserBuilder::new(&data).parse().unwrap();
598/// let collector = ReplayDataCollector::new();
599/// let replay_data = collector.get_replay_data(&replay).unwrap();
600///
601/// // Access replay metadata
602/// println!("Team 0 players: {}", replay_data.meta.team_zero.len());
603///
604/// // Access frame data
605/// println!("Total frames: {}", replay_data.frame_data.metadata_frames.len());
606///
607/// // Access demolition events
608/// println!("Total demolitions: {}", replay_data.demolish_infos.len());
609/// ```
610#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
611#[ts(export)]
612pub struct ReplayData {
613 /// All frame-by-frame data including ball, player, and metadata information
614 pub frame_data: FrameData,
615 /// Replay metadata including player information, game settings, and statistics
616 pub meta: ReplayMeta,
617 /// Information about all demolition events that occurred during the replay
618 pub demolish_infos: Vec<DemolishInfo>,
619 /// Exact boost pad pickup and availability events observed during the replay
620 pub boost_pad_events: Vec<BoostPadEvent>,
621 /// Resolved standard boost pad layout annotated with replay pad ids when known
622 pub boost_pads: Vec<ResolvedBoostPad>,
623 /// Replay-authored team touch markers observed during the replay
624 pub touch_events: Vec<TouchEvent>,
625 /// Exact dodge refresh events observed via the replay's refreshed-dodge counter
626 pub dodge_refreshed_events: Vec<DodgeRefreshedEvent>,
627 /// Coalesced camera/vehicle-toggle changes (ball cam, behind-view, driving)
628 /// grouped by player — the player id is stored once and each entry holds
629 /// that player's frame-ordered changes, rather than a value per frame.
630 #[ts(as = "Vec<(crate::interop::ts_bindings::RemoteIdTs, Vec<PlayerCameraStateChange>)>")]
631 pub player_camera_events: Vec<(PlayerId, Vec<PlayerCameraStateChange>)>,
632 /// Exact player stat counter increments observed during the replay
633 pub player_stat_events: Vec<PlayerStatEvent>,
634 /// Exact goal events observed during the replay
635 pub goal_events: Vec<GoalEvent>,
636 /// Replay-authored tick marks/bookmarks from the replay body
637 pub replay_tick_marks: Vec<ReplayTickMark>,
638}
639
640impl ReplayData {
641 /// Serializes the replay data to a JSON string.
642 ///
643 /// # Returns
644 ///
645 /// Returns a [`Result`] containing either the JSON string representation
646 /// of the replay data or a [`serde_json::Error`] if serialization fails.
647 ///
648 /// # Example
649 ///
650 /// ```rust
651 /// use subtr_actor::collector::replay_data::ReplayDataCollector;
652 /// use boxcars::ParserBuilder;
653 ///
654 /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
655 /// let replay = ParserBuilder::new(&data).parse().unwrap();
656 /// let collector = ReplayDataCollector::new();
657 /// let replay_data = collector.get_replay_data(&replay).unwrap();
658 ///
659 /// let json_string = replay_data.as_json().unwrap();
660 /// println!("Replay as JSON: {}", json_string);
661 /// ```
662 pub fn as_json(&self) -> Result<String, serde_json::Error> {
663 serde_json::to_string(self)
664 }
665
666 /// Serializes the replay data to a pretty-printed JSON string.
667 ///
668 /// # Returns
669 ///
670 /// Returns a [`Result`] containing either the pretty-printed JSON string
671 /// representation of the replay data or a [`serde_json::Error`] if serialization fails.
672 pub fn as_pretty_json(&self) -> Result<String, serde_json::Error> {
673 serde_json::to_string_pretty(self)
674 }
675}
676
677fn replay_tick_marks(
678 replay: &boxcars::Replay,
679 metadata_frames: &[MetadataFrame],
680) -> Vec<ReplayTickMark> {
681 replay
682 .tick_marks
683 .iter()
684 .map(|tick_mark| ReplayTickMark {
685 description: tick_mark.description.clone(),
686 frame: tick_mark.frame,
687 time: usize::try_from(tick_mark.frame)
688 .ok()
689 .and_then(|frame| metadata_frames.get(frame))
690 .map(|frame| frame.time),
691 })
692 .collect()
693}
694
695/// Groups the processor's flat `(player, change)` camera stream by player,
696/// preserving first-appearance player order and per-player frame order, so the
697/// serialized form stores each player id once instead of per change.
698pub(crate) fn group_player_camera_events(
699 events: &[(PlayerId, PlayerCameraStateChange)],
700) -> Vec<(PlayerId, Vec<PlayerCameraStateChange>)> {
701 let mut grouped: Vec<(PlayerId, Vec<PlayerCameraStateChange>)> = Vec::new();
702 for (player_id, change) in events {
703 if let Some((_, changes)) = grouped.iter_mut().find(|(id, _)| id == player_id) {
704 changes.push(change.clone());
705 } else {
706 grouped.push((player_id.clone(), vec![change.clone()]));
707 }
708 }
709 grouped
710}
711
712#[cfg(test)]
713pub(crate) fn player_stat_events_with_shot_saves(
714 player_stat_events: &[PlayerStatEvent],
715) -> Vec<PlayerStatEvent> {
716 player_stat_events_with_shot_saves_and_frame_data(player_stat_events, None, None)
717}
718
719fn player_stat_events_with_shot_saves_and_frame_data(
720 player_stat_events: &[PlayerStatEvent],
721 frame_data: Option<&FrameData>,
722 touch_events: Option<&[TouchEvent]>,
723) -> Vec<PlayerStatEvent> {
724 const MAX_SHOT_SAVE_LINK_SECONDS: f32 = 3.0;
725
726 let mut annotated_events = player_stat_events.to_vec();
727 let mut pending_shot_indices: Vec<usize> = Vec::new();
728
729 for index in 0..annotated_events.len() {
730 let current_time = annotated_events[index].time;
731 pending_shot_indices.retain(|shot_index| {
732 current_time - annotated_events[*shot_index].time <= MAX_SHOT_SAVE_LINK_SECONDS
733 });
734
735 match annotated_events[index].kind {
736 PlayerStatEventKind::Shot => {
737 if annotated_events[index].shot.is_some() {
738 pending_shot_indices.push(index);
739 }
740 }
741 PlayerStatEventKind::Save => {
742 let save = ShotSaveMetadata {
743 time: annotated_events[index].time,
744 frame: annotated_events[index].frame,
745 player: annotated_events[index].player.clone(),
746 player_position: annotated_events[index].player_position,
747 is_team_0: annotated_events[index].is_team_0,
748 };
749 let Some(pending_position) = pending_shot_indices.iter().rposition(|shot_index| {
750 let shot_event = &annotated_events[*shot_index];
751 if shot_event.is_team_0 == annotated_events[index].is_team_0 {
752 return false;
753 }
754 let save_time_after_shot = annotated_events[index].time - shot_event.time;
755 if save_time_after_shot <= 0.0
756 || save_time_after_shot > MAX_SHOT_SAVE_LINK_SECONDS
757 {
758 return false;
759 }
760 shot_event
761 .shot
762 .as_ref()
763 .and_then(|shot| shot.projected_goal_line_crossing.as_ref())
764 .is_none_or(|crossing| {
765 shot_goal_line_crossing_is_after_save_reference(
766 shot_event,
767 &save,
768 crossing,
769 touch_events,
770 )
771 })
772 }) else {
773 continue;
774 };
775 let shot_index = pending_shot_indices.remove(pending_position);
776 let should_estimate_crossing = annotated_events[shot_index]
777 .shot
778 .as_ref()
779 .is_some_and(|shot| {
780 shot.projected_goal_line_crossing
781 .as_ref()
782 .is_none_or(|crossing| !crossing.inside_goal_mouth)
783 });
784 let estimated_crossing = should_estimate_crossing.then(|| {
785 frame_data.and_then(|frame_data| {
786 estimate_saved_shot_goal_line_crossing(
787 &annotated_events[shot_index],
788 &save,
789 frame_data,
790 touch_events,
791 )
792 })
793 });
794 let unavailable_reason = estimated_crossing
795 .as_ref()
796 .is_none_or(Option::is_none)
797 .then(|| {
798 frame_data.and_then(|frame_data| {
799 saved_shot_goal_line_crossing_unavailable_reason(
800 &annotated_events[shot_index],
801 &save,
802 frame_data,
803 touch_events,
804 )
805 })
806 });
807 if let Some(shot) = annotated_events[shot_index].shot.as_mut() {
808 if let Some(Some(estimated_crossing)) = estimated_crossing {
809 shot.projected_goal_target_hit = Some(
810 ShotGoalTargetHit::from_goal_line_crossing(&estimated_crossing),
811 );
812 shot.projected_goal_line_crossing = Some(estimated_crossing);
813 shot.projected_goal_line_crossing_unavailable_reason = None;
814 } else if shot
815 .projected_goal_line_crossing
816 .as_ref()
817 .is_some_and(saved_shot_crossing_is_unphysical_free_flight)
818 {
819 shot.projected_goal_line_crossing = None;
820 }
821 if shot.projected_goal_line_crossing.is_none() {
822 if let Some(Some(unavailable_reason)) = unavailable_reason {
823 shot.projected_goal_line_crossing_unavailable_reason =
824 Some(unavailable_reason);
825 }
826 } else {
827 shot.projected_goal_line_crossing_unavailable_reason = None;
828 }
829 shot.resulting_save = Some(save);
830 }
831 }
832 PlayerStatEventKind::Assist => {}
833 }
834 }
835
836 annotated_events
837}
838
839fn estimate_saved_shot_goal_line_crossing(
840 shot_event: &PlayerStatEvent,
841 save: &ShotSaveMetadata,
842 frame_data: &FrameData,
843 touch_events: Option<&[TouchEvent]>,
844) -> Option<ShotGoalLineCrossing> {
845 const MAX_SAVE_TOUCH_STAT_LAG_SECONDS: f32 = 0.25;
846
847 let prediction_window = saved_shot_prediction_window(shot_event, save, touch_events);
848 estimate_saved_shot_goal_line_crossing_in_window(shot_event, frame_data, prediction_window)
849 .or_else(|| {
850 let lagged_prediction_window = saved_shot_prediction_window_with_save_touch_lag(
851 shot_event,
852 save,
853 touch_events,
854 MAX_SAVE_TOUCH_STAT_LAG_SECONDS,
855 );
856 (lagged_prediction_window.has_save_touch
857 && !prediction_window.has_save_touch
858 && lagged_prediction_window.estimation_time < prediction_window.shot_time)
859 .then(|| {
860 estimate_saved_shot_goal_line_crossing_in_window(
861 shot_event,
862 frame_data,
863 lagged_prediction_window,
864 )
865 })
866 .flatten()
867 })
868}
869
870fn estimate_saved_shot_goal_line_crossing_in_window(
871 shot_event: &PlayerStatEvent,
872 frame_data: &FrameData,
873 prediction_window: SavedShotPredictionWindow,
874) -> Option<ShotGoalLineCrossing> {
875 const MAX_PRE_SAVE_LOOKBACK_SECONDS: f32 = 3.0;
876 const MAX_NO_TOUCH_SHOT_STAT_LAG_SECONDS: f32 = 0.1;
877 const FLOAT_EPSILON: f32 = 0.0001;
878
879 shot_event.shot.as_ref()?;
880
881 let target_direction = if shot_event.is_team_0 { 1.0 } else { -1.0 };
882 let estimation_frame = prediction_window
883 .estimation_frame
884 .min(frame_data.ball_data.frames.len().saturating_sub(1));
885 let mut fallback_crossing = None;
886 for frame_index in (0..=estimation_frame).rev() {
887 let Some(metadata) = frame_data.metadata_frames.get(frame_index) else {
888 continue;
889 };
890 if metadata.time > prediction_window.estimation_time + FLOAT_EPSILON {
891 continue;
892 }
893 if prediction_window.estimation_time - metadata.time > MAX_PRE_SAVE_LOOKBACK_SECONDS {
894 break;
895 }
896 if prediction_window.has_inferred_shot_touch
897 && metadata.time + FLOAT_EPSILON < prediction_window.shot_time
898 {
899 break;
900 }
901
902 let Some(BallFrame::Data { rigid_body }) = frame_data.ball_data.frames.get(frame_index)
903 else {
904 continue;
905 };
906 let Some(velocity) = rigid_body.linear_velocity else {
907 continue;
908 };
909 if target_direction * velocity.y <= 0.0 {
910 continue;
911 }
912
913 let Some(mut crossing) = ShotGoalLineCrossing::predict_saved_shot_from_rigid_body(
914 shot_event.is_team_0,
915 rigid_body,
916 ) else {
917 continue;
918 };
919 let crossing_time = metadata.time + crossing.time_after_shot;
920 let mut prediction_start_time = prediction_window.shot_time;
921 let mut prediction_start_frame = prediction_window.shot_frame;
922 if crossing_time <= prediction_window.shot_time + FLOAT_EPSILON {
923 if prediction_window.has_inferred_shot_touch
924 || prediction_window.has_save_touch
925 || prediction_window.shot_time - crossing_time > MAX_NO_TOUCH_SHOT_STAT_LAG_SECONDS
926 || crossing_time <= metadata.time + FLOAT_EPSILON
927 {
928 continue;
929 }
930 prediction_start_time = metadata.time;
931 prediction_start_frame = frame_index;
932 }
933 if prediction_window.has_save_touch
934 && crossing_time <= prediction_window.estimation_time + FLOAT_EPSILON
935 {
936 continue;
937 }
938 crossing.time_after_shot = crossing_time - prediction_start_time;
939 crossing.prediction_start_time = Some(prediction_start_time);
940 crossing.prediction_start_frame = Some(prediction_start_frame);
941
942 if crossing.inside_goal_mouth {
943 return Some(crossing);
944 }
945 fallback_crossing.get_or_insert(crossing);
946 }
947
948 fallback_crossing
949}
950
951fn saved_shot_goal_line_crossing_unavailable_reason(
952 shot_event: &PlayerStatEvent,
953 save: &ShotSaveMetadata,
954 frame_data: &FrameData,
955 touch_events: Option<&[TouchEvent]>,
956) -> Option<ShotGoalLineCrossingUnavailableReason> {
957 let prediction_window = saved_shot_prediction_window(shot_event, save, touch_events);
958 Some(saved_shot_goal_line_crossing_unavailable_reason_in_window(
959 shot_event,
960 save,
961 frame_data,
962 prediction_window,
963 ))
964}
965
966fn saved_shot_goal_line_crossing_unavailable_reason_in_window(
967 shot_event: &PlayerStatEvent,
968 save: &ShotSaveMetadata,
969 frame_data: &FrameData,
970 prediction_window: SavedShotPredictionWindow,
971) -> ShotGoalLineCrossingUnavailableReason {
972 const MAX_PRE_SAVE_LOOKBACK_SECONDS: f32 = 3.0;
973 const FLOAT_EPSILON: f32 = 0.0001;
974
975 let target_direction = if shot_event.is_team_0 { 1.0 } else { -1.0 };
976 let estimation_frame = prediction_window
977 .estimation_frame
978 .min(frame_data.ball_data.frames.len().saturating_sub(1));
979 let mut saw_velocity = false;
980 let mut inbound_frame_count = 0;
981 let mut projected_inbound_frame_count = 0;
982 let mut unphysical_free_flight_count = 0;
983 let mut crossing_before_or_at_prediction_start_count = 0;
984 let mut crossing_before_or_at_save_touch_count = 0;
985 let mut crossing_before_or_at_save_count = 0;
986
987 for frame_index in (0..=estimation_frame).rev() {
988 let Some(metadata) = frame_data.metadata_frames.get(frame_index) else {
989 continue;
990 };
991 if metadata.time > prediction_window.estimation_time + FLOAT_EPSILON {
992 continue;
993 }
994 if prediction_window.estimation_time - metadata.time > MAX_PRE_SAVE_LOOKBACK_SECONDS {
995 break;
996 }
997 if prediction_window.has_inferred_shot_touch
998 && metadata.time + FLOAT_EPSILON < prediction_window.shot_time
999 {
1000 break;
1001 }
1002
1003 let Some(BallFrame::Data { rigid_body }) = frame_data.ball_data.frames.get(frame_index)
1004 else {
1005 continue;
1006 };
1007 let Some(velocity) = rigid_body.linear_velocity else {
1008 continue;
1009 };
1010 saw_velocity = true;
1011 if target_direction * velocity.y <= 0.0 {
1012 continue;
1013 }
1014
1015 inbound_frame_count += 1;
1016 let Some((crossing_time, unphysical_free_flight)) =
1017 saved_shot_diagnostic_crossing_time(shot_event.is_team_0, rigid_body)
1018 else {
1019 continue;
1020 };
1021 projected_inbound_frame_count += 1;
1022 if unphysical_free_flight {
1023 unphysical_free_flight_count += 1;
1024 continue;
1025 }
1026
1027 let absolute_crossing_time = metadata.time + crossing_time;
1028 if absolute_crossing_time <= prediction_window.shot_time + FLOAT_EPSILON {
1029 crossing_before_or_at_prediction_start_count += 1;
1030 continue;
1031 }
1032 if prediction_window.has_save_touch
1033 && absolute_crossing_time <= prediction_window.estimation_time + FLOAT_EPSILON
1034 {
1035 crossing_before_or_at_save_touch_count += 1;
1036 continue;
1037 }
1038 if absolute_crossing_time <= save.time + FLOAT_EPSILON {
1039 crossing_before_or_at_save_count += 1;
1040 continue;
1041 }
1042
1043 return ShotGoalLineCrossingUnavailableReason::NoUsableProjection;
1044 }
1045
1046 if !saw_velocity {
1047 return ShotGoalLineCrossingUnavailableReason::NoBallVelocity;
1048 }
1049 if inbound_frame_count == 0 {
1050 return ShotGoalLineCrossingUnavailableReason::NoGoalwardBallBeforeSaveReference;
1051 }
1052 if projected_inbound_frame_count == 0 {
1053 return ShotGoalLineCrossingUnavailableReason::NoGoalLineCrossingBeforeSaveReference;
1054 }
1055 if unphysical_free_flight_count == projected_inbound_frame_count {
1056 return ShotGoalLineCrossingUnavailableReason::OnlyUnphysicalFreeFlightCrossings;
1057 }
1058 if crossing_before_or_at_prediction_start_count == projected_inbound_frame_count {
1059 return ShotGoalLineCrossingUnavailableReason::CrossingsBeforePredictionStart;
1060 }
1061 if crossing_before_or_at_save_touch_count == projected_inbound_frame_count {
1062 return ShotGoalLineCrossingUnavailableReason::CrossingsBeforeSaveTouch;
1063 }
1064 if crossing_before_or_at_save_count == projected_inbound_frame_count {
1065 return ShotGoalLineCrossingUnavailableReason::CrossingsBeforeSaveStat;
1066 }
1067
1068 ShotGoalLineCrossingUnavailableReason::NoUsableProjection
1069}
1070
1071fn saved_shot_diagnostic_crossing_time(
1072 is_team_0: bool,
1073 rigid_body: &boxcars::RigidBody,
1074) -> Option<(f32, bool)> {
1075 let crossing_config = BallGoalLineCrossingConfig::attacking_goal(is_team_0);
1076 let surfaces = standard_soccar_goal_line_prediction_field_surfaces();
1077 predict_ball_with_surface_bounces_goal_line_crossing(
1078 rigid_body,
1079 crossing_config,
1080 BallTrajectoryConfig::STANDARD_SOCCAR,
1081 BallBounceConfig::STANDARD_SOCCAR,
1082 &surfaces,
1083 )
1084 .map(|crossing| (crossing.time, false))
1085 .or_else(|| {
1086 predict_free_flight_goal_line_crossing(
1087 rigid_body,
1088 crossing_config,
1089 BallTrajectoryConfig::STANDARD_SOCCAR,
1090 )
1091 .map(|crossing| {
1092 (
1093 crossing.time,
1094 crossing.position.z < STANDARD_BALL_RADIUS - STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN,
1095 )
1096 })
1097 })
1098}
1099
1100fn saved_shot_crossing_is_unphysical_free_flight(crossing: &ShotGoalLineCrossing) -> bool {
1101 matches!(
1102 crossing.prediction_kind,
1103 ShotGoalLineCrossingPredictionKind::FreeFlight
1104 | ShotGoalLineCrossingPredictionKind::SavedShotPreSaveFreeFlight
1105 ) && crossing.position.z < STANDARD_BALL_RADIUS - STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN
1106}
1107
1108fn shot_goal_line_crossing_is_after_save_reference(
1109 shot_event: &PlayerStatEvent,
1110 save: &ShotSaveMetadata,
1111 crossing: &ShotGoalLineCrossing,
1112 touch_events: Option<&[TouchEvent]>,
1113) -> bool {
1114 const FLOAT_EPSILON: f32 = 0.0001;
1115
1116 let crossing_time =
1117 crossing.prediction_start_time.unwrap_or(shot_event.time) + crossing.time_after_shot;
1118 let save_reference_time =
1119 saved_shot_prediction_window(shot_event, save, touch_events).save_reference_time();
1120 crossing_time > save_reference_time + FLOAT_EPSILON
1121}
1122
1123#[derive(Debug, Clone, Copy)]
1124struct SavedShotPredictionWindow {
1125 shot_frame: usize,
1126 shot_time: f32,
1127 has_inferred_shot_touch: bool,
1128 has_save_touch: bool,
1129 estimation_frame: usize,
1130 estimation_time: f32,
1131}
1132
1133impl SavedShotPredictionWindow {
1134 fn save_reference_time(self) -> f32 {
1135 if self.has_save_touch {
1136 self.estimation_time
1137 } else {
1138 self.estimation_time.max(self.shot_time)
1139 }
1140 }
1141}
1142
1143fn saved_shot_prediction_window(
1144 shot_event: &PlayerStatEvent,
1145 save: &ShotSaveMetadata,
1146 touch_events: Option<&[TouchEvent]>,
1147) -> SavedShotPredictionWindow {
1148 saved_shot_prediction_window_with_save_touch_lag(shot_event, save, touch_events, 0.0)
1149}
1150
1151fn saved_shot_prediction_window_with_save_touch_lag(
1152 shot_event: &PlayerStatEvent,
1153 save: &ShotSaveMetadata,
1154 touch_events: Option<&[TouchEvent]>,
1155 max_save_touch_stat_lag_seconds: f32,
1156) -> SavedShotPredictionWindow {
1157 const FLOAT_EPSILON: f32 = 0.0001;
1158 const MAX_SHOT_TOUCH_LOOKBACK_SECONDS: f32 = 3.0;
1159
1160 let save_touch = touch_events.and_then(|touch_events| {
1161 let player_touch = touch_events.iter().rev().find(|touch| {
1162 touch.team_is_team_0 == save.is_team_0
1163 && touch.player.as_ref() == Some(&save.player)
1164 && touch.time >= shot_event.time - max_save_touch_stat_lag_seconds - FLOAT_EPSILON
1165 && touch.time <= save.time + FLOAT_EPSILON
1166 });
1167 let team_touch = || {
1168 touch_events.iter().rev().find(|touch| {
1169 touch.team_is_team_0 == save.is_team_0
1170 && touch.time
1171 >= shot_event.time - max_save_touch_stat_lag_seconds - FLOAT_EPSILON
1172 && touch.time <= save.time + FLOAT_EPSILON
1173 })
1174 };
1175 player_touch.or_else(team_touch)
1176 });
1177 let shot_touch = touch_events.and_then(|touch_events| {
1178 let player_touch = touch_events.iter().rev().find(|touch| {
1179 touch.team_is_team_0 == shot_event.is_team_0
1180 && touch.player.as_ref() == Some(&shot_event.player)
1181 && touch.time >= shot_event.time - MAX_SHOT_TOUCH_LOOKBACK_SECONDS - FLOAT_EPSILON
1182 && touch.time <= shot_event.time + FLOAT_EPSILON
1183 });
1184 let team_touch = || {
1185 touch_events.iter().rev().find(|touch| {
1186 touch.team_is_team_0 == shot_event.is_team_0
1187 && touch.time
1188 >= shot_event.time - MAX_SHOT_TOUCH_LOOKBACK_SECONDS - FLOAT_EPSILON
1189 && touch.time <= shot_event.time + FLOAT_EPSILON
1190 })
1191 };
1192 player_touch.or_else(team_touch)
1193 });
1194
1195 let (estimation_frame, estimation_time) = save_touch
1196 .map(|touch| {
1197 let frame = if touch.frame > 0 {
1198 touch.frame - 1
1199 } else {
1200 touch.frame
1201 };
1202 (frame, touch.time)
1203 })
1204 .unwrap_or((save.frame, save.time));
1205 let has_save_touch = save_touch.is_some();
1206 let inferred_shot_touch =
1207 shot_touch.filter(|touch| touch.time <= estimation_time + FLOAT_EPSILON);
1208 let has_inferred_shot_touch = inferred_shot_touch.is_some();
1209 let (shot_frame, shot_time) = inferred_shot_touch
1210 .map(|touch| (touch.frame, touch.time))
1211 .unwrap_or((shot_event.frame, shot_event.time));
1212
1213 if shot_frame <= estimation_frame {
1214 SavedShotPredictionWindow {
1215 shot_frame,
1216 shot_time,
1217 has_inferred_shot_touch,
1218 has_save_touch,
1219 estimation_frame,
1220 estimation_time,
1221 }
1222 } else {
1223 SavedShotPredictionWindow {
1224 shot_frame: shot_event.frame,
1225 shot_time: shot_event.time,
1226 has_inferred_shot_touch: false,
1227 has_save_touch,
1228 estimation_frame,
1229 estimation_time,
1230 }
1231 }
1232}
1233
1234impl FrameData {
1235 /// Creates a new empty [`FrameData`] instance.
1236 ///
1237 /// # Returns
1238 ///
1239 /// Returns a new [`FrameData`] with empty ball data, player data, and metadata frames.
1240 fn new() -> Self {
1241 FrameData {
1242 ball_data: BallData::new(),
1243 players: Vec::new(),
1244 metadata_frames: Vec::new(),
1245 }
1246 }
1247
1248 /// Returns the total number of frames in this frame data.
1249 ///
1250 /// # Returns
1251 ///
1252 /// Returns the number of metadata frames, which represents the total frame count.
1253 pub fn frame_count(&self) -> usize {
1254 self.metadata_frames.len()
1255 }
1256
1257 /// Returns the duration of the replay in seconds.
1258 ///
1259 /// # Returns
1260 ///
1261 /// Returns the time of the last frame, or 0.0 if no frames exist.
1262 pub fn duration(&self) -> f32 {
1263 self.metadata_frames.last().map(|f| f.time).unwrap_or(0.0)
1264 }
1265
1266 /// Adds a complete frame of data to the frame data structure.
1267 ///
1268 /// This method adds metadata, ball data, and player data for a single frame
1269 /// to their respective collections, maintaining frame synchronization across
1270 /// all data types.
1271 ///
1272 /// # Arguments
1273 ///
1274 /// * `frame_metadata` - The metadata for this frame (time, game state, etc.)
1275 /// * `ball_frame` - The ball state for this frame
1276 /// * `player_frames` - Player state data for all players in this frame
1277 ///
1278 /// # Returns
1279 ///
1280 /// Returns a [`SubtrActorResult`] indicating success or failure of the operation.
1281 ///
1282 /// # Errors
1283 ///
1284 /// May return a [`SubtrActorError`] if frame data cannot be processed correctly.
1285 fn add_frame(
1286 &mut self,
1287 frame_metadata: MetadataFrame,
1288 ball_frame: BallFrame,
1289 player_frames: Vec<(PlayerId, PlayerFrame)>,
1290 ) -> SubtrActorResult<()> {
1291 let frame_index = self.metadata_frames.len();
1292 self.metadata_frames.push(frame_metadata);
1293 self.ball_data.add_frame(frame_index, ball_frame);
1294 for (player_id, frame) in player_frames {
1295 self.players
1296 .get_entry(player_id)
1297 .or_insert_with(PlayerData::new)
1298 .add_frame(frame_index, frame)
1299 }
1300 Ok(())
1301 }
1302}
1303
1304/// A collector that extracts comprehensive frame-by-frame data from Rocket League replays.
1305///
1306/// [`ReplayDataCollector`] implements the [`Collector`] trait to process replay frames
1307/// and extract detailed information about ball movement, player actions, and game state.
1308/// It builds a complete [`ReplayData`] structure containing all available information
1309/// from the replay.
1310///
1311/// # Usage
1312///
1313/// The collector is designed to be used with the [`ReplayProcessor`] to extract
1314/// comprehensive replay data:
1315///
1316/// ```rust
1317/// use subtr_actor::collector::replay_data::ReplayDataCollector;
1318/// use boxcars::ParserBuilder;
1319///
1320/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
1321/// let replay = ParserBuilder::new(&data).parse().unwrap();
1322///
1323/// let collector = ReplayDataCollector::new();
1324/// let replay_data = collector.get_replay_data(&replay).unwrap();
1325///
1326/// // Process the extracted data
1327/// for (frame_idx, metadata) in replay_data.frame_data.metadata_frames.iter().enumerate() {
1328/// println!("Frame {}: Time={:.2}s, Remaining={}s",
1329/// frame_idx, metadata.time, metadata.seconds_remaining);
1330/// }
1331/// ```
1332///
1333/// # Fields
1334///
1335/// * `frame_data` - Internal storage for frame-by-frame data during collection
1336pub struct ReplayDataCollector {
1337 /// Internal storage for frame-by-frame data during collection
1338 frame_data: FrameData,
1339}
1340
1341impl Default for ReplayDataCollector {
1342 /// Creates a default [`ReplayDataCollector`] instance.
1343 ///
1344 /// This is equivalent to calling [`ReplayDataCollector::new()`].
1345 fn default() -> Self {
1346 Self::new()
1347 }
1348}
1349
1350impl ReplayDataCollector {
1351 /// Creates a new [`ReplayDataCollector`] instance.
1352 ///
1353 /// # Returns
1354 ///
1355 /// Returns a new collector ready to process replay frames.
1356 pub fn new() -> Self {
1357 ReplayDataCollector {
1358 frame_data: FrameData::new(),
1359 }
1360 }
1361
1362 /// Consumes the collector and returns the collected frame data.
1363 ///
1364 /// # Returns
1365 ///
1366 /// Returns the [`FrameData`] containing all processed frame information.
1367 pub fn get_frame_data(self) -> FrameData {
1368 self.frame_data
1369 }
1370
1371 pub fn into_replay_data(self, processor: ReplayProcessor<'_>) -> SubtrActorResult<ReplayData> {
1372 let meta = processor.get_replay_meta()?;
1373 let frame_data = self.get_frame_data();
1374 Ok(ReplayData {
1375 meta,
1376 demolish_infos: processor.demolishes().to_vec(),
1377 boost_pad_events: processor.boost_pad_events().to_vec(),
1378 boost_pads: processor.resolved_boost_pads(),
1379 touch_events: processor.touch_events().to_vec(),
1380 dodge_refreshed_events: processor.dodge_refreshed_events().to_vec(),
1381 player_camera_events: group_player_camera_events(processor.player_camera_events()),
1382 player_stat_events: player_stat_events_with_shot_saves_and_frame_data(
1383 processor.player_stat_events(),
1384 Some(&frame_data),
1385 Some(processor.touch_events()),
1386 ),
1387 goal_events: processor.goal_events().to_vec(),
1388 replay_tick_marks: replay_tick_marks(processor.replay, &frame_data.metadata_frames),
1389 frame_data,
1390 })
1391 }
1392
1393 /// Processes a replay and returns complete replay data.
1394 ///
1395 /// This method processes the entire replay using a [`ReplayProcessor`] and
1396 /// extracts all available information including frame-by-frame data, metadata,
1397 /// and special events like demolitions.
1398 ///
1399 /// # Arguments
1400 ///
1401 /// * `replay` - The parsed replay data from the [`boxcars`] library
1402 ///
1403 /// # Returns
1404 ///
1405 /// Returns a [`SubtrActorResult`] containing the complete [`ReplayData`] structure
1406 /// with all extracted information.
1407 ///
1408 /// # Errors
1409 ///
1410 /// Returns a [`SubtrActorError`] if:
1411 /// - The replay processor cannot be created
1412 /// - Frame processing fails
1413 /// - Replay metadata cannot be extracted
1414 ///
1415 /// # Example
1416 ///
1417 /// ```rust
1418 /// use subtr_actor::collector::replay_data::ReplayDataCollector;
1419 /// use boxcars::ParserBuilder;
1420 ///
1421 /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
1422 /// let replay = ParserBuilder::new(&data).parse().unwrap();
1423 ///
1424 /// let collector = ReplayDataCollector::new();
1425 /// let replay_data = collector.get_replay_data(&replay).unwrap();
1426 ///
1427 /// println!("Processed {} frames", replay_data.frame_data.frame_count());
1428 /// ```
1429 pub fn get_replay_data(mut self, replay: &boxcars::Replay) -> SubtrActorResult<ReplayData> {
1430 let mut processor = ReplayProcessor::new(replay)?;
1431 processor.process_all(&mut [&mut self])?;
1432 self.into_replay_data(processor)
1433 }
1434
1435 /// Extracts player frame data for all players at the specified time.
1436 ///
1437 /// This method iterates through all players in the replay and extracts their
1438 /// state information at the given time, returning a vector of player frames
1439 /// indexed by player ID.
1440 ///
1441 /// # Arguments
1442 ///
1443 /// * `processor` - The [`ReplayProcessor`] containing the replay data
1444 /// * `current_time` - The time in seconds at which to extract player states
1445 ///
1446 /// # Returns
1447 ///
1448 /// Returns a [`SubtrActorResult`] containing a vector of tuples with player IDs
1449 /// and their corresponding [`PlayerFrame`] data.
1450 ///
1451 /// # Errors
1452 ///
1453 /// Returns a [`SubtrActorError`] if player frame data cannot be extracted.
1454 fn get_player_frames(
1455 &self,
1456 processor: &dyn ProcessorView,
1457 current_time: f32,
1458 ) -> SubtrActorResult<Vec<(PlayerId, PlayerFrame)>> {
1459 Ok(processor
1460 .iter_player_ids_in_order()
1461 .map(|player_id| {
1462 (
1463 player_id.clone(),
1464 PlayerFrame::new_from_processor(processor, player_id, current_time)
1465 .unwrap_or(PlayerFrame::Empty),
1466 )
1467 })
1468 .collect())
1469 }
1470}
1471
1472impl Collector for ReplayDataCollector {
1473 /// Processes a single frame of the replay and extracts all relevant data.
1474 ///
1475 /// This method is called by the [`ReplayProcessor`] for each frame in the replay.
1476 /// It extracts metadata, ball state, and player state information and adds them
1477 /// to the internal frame data structure.
1478 ///
1479 /// # Arguments
1480 ///
1481 /// * `processor` - The [`ReplayProcessor`] containing the replay data and context
1482 /// * `_frame` - The current frame data (unused in this implementation)
1483 /// * `_frame_number` - The current frame number (unused in this implementation)
1484 /// * `current_time` - The current time in seconds since the start of the replay
1485 ///
1486 /// # Returns
1487 ///
1488 /// Returns a [`SubtrActorResult`] containing [`TimeAdvance::NextFrame`] to
1489 /// indicate that processing should continue to the next frame.
1490 ///
1491 /// # Errors
1492 ///
1493 /// Returns a [`SubtrActorError`] if:
1494 /// - Metadata frame cannot be created
1495 /// - Player frame data cannot be extracted
1496 /// - Frame data cannot be added to the collection
1497 fn process_frame(
1498 &mut self,
1499 processor: &dyn ProcessorView,
1500 _frame: &boxcars::Frame,
1501 _frame_number: usize,
1502 current_time: f32,
1503 ) -> SubtrActorResult<TimeAdvance> {
1504 let metadata_frame = MetadataFrame::new_from_processor(processor, current_time)?;
1505 let ball_frame = BallFrame::new_from_processor(processor, current_time);
1506 let player_frames = self.get_player_frames(processor, current_time)?;
1507 self.frame_data
1508 .add_frame(metadata_frame, ball_frame, player_frames)?;
1509 Ok(TimeAdvance::NextFrame)
1510 }
1511}