Skip to main content

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}