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 if let Ok(rigid_body) = processor.get_interpolated_ball_rigid_body(current_time, 0.0)
89        {
90            Self::new_from_rigid_body(rigid_body)
91        } else {
92            Self::Empty
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/// Represents a player's state for a single frame in a Rocket League replay.
113///
114/// Contains comprehensive information about a player's position, movement,
115/// and control inputs during a specific frame of the replay.
116///
117/// # Variants
118///
119/// - [`Empty`](PlayerFrame::Empty) - Indicates the player state is unavailable
120/// - [`Data`](PlayerFrame::Data) - Contains the player's complete state information
121#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub enum PlayerFrame {
124    /// Empty frame indicating the player state is unavailable
125    Empty,
126    /// Frame containing the player's complete state data
127    Data {
128        /// The player's rigid body containing position, rotation, and velocity information
129        #[ts(as = "crate::interop::ts_bindings::RigidBodyTs")]
130        rigid_body: boxcars::RigidBody,
131        /// The player's current boost amount in raw replay units (0.0 to 255.0)
132        boost_amount: f32,
133        /// Whether the player is actively using boost
134        boost_active: bool,
135        /// Whether the player is actively powersliding / holding handbrake
136        powerslide_active: bool,
137        /// Whether the player is actively jumping
138        jump_active: bool,
139        /// Whether the player is performing a double jump
140        double_jump_active: bool,
141        /// Whether the player is performing a dodge maneuver
142        dodge_active: bool,
143        /// The player's name as it appears in the replay
144        player_name: Option<String>,
145        /// The team the player belongs to (0 or 1)
146        team: Option<i32>,
147        /// Whether the player is on team 0 (blue team typically)
148        is_team_0: Option<bool>,
149    },
150}
151
152impl PlayerFrame {
153    /// Creates a new [`PlayerFrame`] from a [`ReplayProcessor`] for a specific player at the specified time.
154    ///
155    /// This method extracts comprehensive player state information from the replay processor,
156    /// including position, control inputs, and team information.
157    ///
158    /// # Arguments
159    ///
160    /// * `processor` - The [`ReplayProcessor`] containing the replay data
161    /// * `player_id` - The unique identifier for the player
162    /// * `current_time` - The time in seconds at which to extract the player state
163    ///
164    /// # Returns
165    ///
166    /// Returns a [`SubtrActorResult`] containing a [`PlayerFrame::Data`] value
167    /// with the player's complete state information.
168    ///
169    /// # Errors
170    ///
171    /// Returns a [`SubtrActorError`] if:
172    /// - The player's rigid body cannot be retrieved
173    fn new_from_processor(
174        processor: &dyn ProcessorView,
175        player_id: &PlayerId,
176        current_time: f32,
177    ) -> SubtrActorResult<Self> {
178        let rigid_body =
179            processor.get_interpolated_player_rigid_body(player_id, current_time, 0.0)?;
180
181        let boost_amount = processor.get_player_boost_level(player_id).unwrap_or(0.0);
182        let boost_active = processor.get_boost_active(player_id).unwrap_or(0) % 2 == 1;
183        let powerslide_active = processor.get_powerslide_active(player_id).unwrap_or(false);
184        let jump_active = processor.get_jump_active(player_id).unwrap_or(0) % 2 == 1;
185        let double_jump_active = processor.get_double_jump_active(player_id).unwrap_or(0) % 2 == 1;
186        let dodge_active = processor.get_dodge_active(player_id).unwrap_or(0) % 2 == 1;
187
188        // Extract player identity information
189        let player_name = processor.get_player_name(player_id).ok();
190        let team = processor
191            .get_player_team_key(player_id)
192            .ok()
193            .and_then(|team_key| team_key.parse::<i32>().ok());
194        let is_team_0 = processor.get_player_is_team_0(player_id).ok();
195
196        Ok(Self::from_data(
197            rigid_body,
198            boost_amount,
199            boost_active,
200            powerslide_active,
201            jump_active,
202            double_jump_active,
203            dodge_active,
204            player_name,
205            team,
206            is_team_0,
207        ))
208    }
209
210    /// Creates a [`PlayerFrame`] from the provided data components.
211    ///
212    /// # Arguments
213    ///
214    /// * `rigid_body` - The player's rigid body physics information
215    /// * `boost_amount` - The player's current boost level in raw replay units (0.0 to 255.0)
216    /// * `boost_active` - Whether the player is actively using boost
217    /// * `powerslide_active` - Whether the player is actively powersliding
218    /// * `jump_active` - Whether the player is actively jumping
219    /// * `double_jump_active` - Whether the player is performing a double jump
220    /// * `dodge_active` - Whether the player is performing a dodge maneuver
221    /// * `player_name` - The player's name, if available
222    /// * `team` - The player's team number, if available
223    /// * `is_team_0` - Whether the player is on team 0, if available
224    ///
225    /// # Returns
226    ///
227    /// Returns [`Data`](PlayerFrame::Data) with all provided information, even
228    /// when the rigid body is sleeping, so stationary kickoff and reset frames
229    /// still retain the player's position for downstream consumers such as the
230    /// JS player.
231    #[allow(clippy::too_many_arguments)]
232    fn from_data(
233        rigid_body: boxcars::RigidBody,
234        boost_amount: f32,
235        boost_active: bool,
236        powerslide_active: bool,
237        jump_active: bool,
238        double_jump_active: bool,
239        dodge_active: bool,
240        player_name: Option<String>,
241        team: Option<i32>,
242        is_team_0: Option<bool>,
243    ) -> Self {
244        Self::Data {
245            rigid_body,
246            boost_amount,
247            boost_active,
248            powerslide_active,
249            jump_active,
250            double_jump_active,
251            dodge_active,
252            player_name,
253            team,
254            is_team_0,
255        }
256    }
257}
258
259/// Contains all frame data for a single player throughout the replay.
260///
261/// This structure holds a chronological sequence of [`PlayerFrame`] instances
262/// representing the player's state at each processed frame of the replay.
263///
264/// # Fields
265///
266/// * `frames` - A vector of [`PlayerFrame`] instances in chronological order
267#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
268#[ts(export)]
269pub struct PlayerData {
270    /// Vector of player frames in chronological order
271    frames: Vec<PlayerFrame>,
272}
273
274impl PlayerData {
275    /// Creates a new empty [`PlayerData`] instance.
276    ///
277    /// # Returns
278    ///
279    /// Returns a new [`PlayerData`] with an empty frames vector.
280    fn new() -> Self {
281        Self { frames: Vec::new() }
282    }
283
284    /// Adds a player frame at the specified frame index.
285    ///
286    /// If the frame index is beyond the current length of the frames vector,
287    /// empty frames will be inserted to fill the gap before adding the new frame.
288    ///
289    /// # Arguments
290    ///
291    /// * `frame_index` - The index at which to insert the frame
292    /// * `frame` - The [`PlayerFrame`] to add
293    fn add_frame(&mut self, frame_index: usize, frame: PlayerFrame) {
294        let empty_frames_to_add = frame_index - self.frames.len();
295        if empty_frames_to_add > 0 {
296            for _ in 0..empty_frames_to_add {
297                self.frames.push(PlayerFrame::Empty)
298            }
299        }
300        self.frames.push(frame)
301    }
302
303    /// Returns a reference to the frames vector.
304    ///
305    /// # Returns
306    ///
307    /// Returns a reference to the vector of [`PlayerFrame`] instances.
308    pub fn frames(&self) -> &Vec<PlayerFrame> {
309        &self.frames
310    }
311
312    /// Returns the number of frames in this player's data.
313    ///
314    /// # Returns
315    ///
316    /// Returns the total number of frames stored for this player.
317    pub fn frame_count(&self) -> usize {
318        self.frames.len()
319    }
320}
321
322/// Contains all frame data for the ball throughout the replay.
323///
324/// This structure holds a chronological sequence of [`BallFrame`] instances
325/// representing the ball's state at each processed frame of the replay.
326///
327/// # Fields
328///
329/// * `frames` - A vector of [`BallFrame`] instances in chronological order
330#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
331#[ts(export)]
332pub struct BallData {
333    /// Vector of ball frames in chronological order
334    frames: Vec<BallFrame>,
335}
336
337impl BallData {
338    /// Creates a new empty [`BallData`] instance.
339    ///
340    /// # Returns
341    ///
342    /// Returns a new [`BallData`] with an empty frames vector.
343    fn new() -> Self {
344        Self { frames: Vec::new() }
345    }
346
347    /// Adds a ball frame at the specified frame index.
348    ///
349    /// If the frame index is beyond the current length of the frames vector,
350    /// empty frames will be inserted to fill the gap before adding the new frame.
351    ///
352    /// # Arguments
353    ///
354    /// * `frame_index` - The index at which to insert the frame
355    /// * `frame` - The [`BallFrame`] to add
356    fn add_frame(&mut self, frame_index: usize, frame: BallFrame) {
357        let empty_frames_to_add = frame_index - self.frames.len();
358        if empty_frames_to_add > 0 {
359            for _ in 0..empty_frames_to_add {
360                self.frames.push(BallFrame::Empty)
361            }
362        }
363        self.frames.push(frame)
364    }
365
366    /// Returns a reference to the frames vector.
367    ///
368    /// # Returns
369    ///
370    /// Returns a reference to the vector of [`BallFrame`] instances.
371    pub fn frames(&self) -> &Vec<BallFrame> {
372        &self.frames
373    }
374
375    /// Returns the number of frames in the ball data.
376    ///
377    /// # Returns
378    ///
379    /// Returns the total number of frames stored for the ball.
380    pub fn frame_count(&self) -> usize {
381        self.frames.len()
382    }
383}
384
385/// Represents game metadata for a single frame in a Rocket League replay.
386///
387/// Contains timing information and game state data that applies to the entire
388/// game at a specific point in time.
389///
390/// # Fields
391///
392/// * `time` - The current time in seconds since the start of the replay
393/// * `seconds_remaining` - The number of seconds remaining in the current game period
394/// * `replicated_game_state_name` - The game state enum value (indicates countdown, playing, goal, etc.)
395/// * `replicated_game_state_time_remaining` - The kickoff countdown timer, usually 3 to 0
396#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
397#[ts(export)]
398pub struct MetadataFrame {
399    /// The current time in seconds since the start of the replay
400    pub time: f32,
401    /// The number of seconds remaining in the current game period
402    pub seconds_remaining: i32,
403    /// The game state enum value (indicates countdown, playing, goal scored, etc.)
404    pub replicated_game_state_name: i32,
405    /// The kickoff countdown timer exposed by the replay metadata actor.
406    pub replicated_game_state_time_remaining: i32,
407}
408
409impl MetadataFrame {
410    /// Creates a new [`MetadataFrame`] from a [`ReplayProcessor`] at the specified time.
411    ///
412    /// # Arguments
413    ///
414    /// * `processor` - The [`ReplayProcessor`] containing the replay data
415    /// * `time` - The current time in seconds since the start of the replay
416    ///
417    /// # Returns
418    ///
419    /// Returns a [`SubtrActorResult`] containing a [`MetadataFrame`] with the
420    /// current time and remaining seconds extracted from the processor.
421    ///
422    /// # Errors
423    ///
424    /// Missing replay metadata fields default to 0 so frame export can continue
425    /// for replays whose metadata actor does not carry every optional property.
426    fn new_from_processor(processor: &dyn ProcessorView, time: f32) -> SubtrActorResult<Self> {
427        Ok(Self::new(
428            time,
429            metadata_i32_or_default(processor.get_seconds_remaining()),
430            metadata_i32_or_default(processor.get_replicated_state_name()),
431            metadata_i32_or_default(processor.get_replicated_game_state_time_remaining()),
432        ))
433    }
434
435    /// Creates a new [`MetadataFrame`] with the specified time, seconds remaining, game state,
436    /// and kickoff countdown value.
437    ///
438    /// # Arguments
439    ///
440    /// * `time` - The current time in seconds since the start of the replay
441    /// * `seconds_remaining` - The number of seconds remaining in the current game period
442    /// * `replicated_game_state_name` - The game state enum value
443    /// * `replicated_game_state_time_remaining` - The kickoff countdown timer
444    ///
445    /// # Returns
446    ///
447    /// Returns a new [`MetadataFrame`] with the provided values.
448    fn new(
449        time: f32,
450        seconds_remaining: i32,
451        replicated_game_state_name: i32,
452        replicated_game_state_time_remaining: i32,
453    ) -> Self {
454        MetadataFrame {
455            time,
456            seconds_remaining,
457            replicated_game_state_name,
458            replicated_game_state_time_remaining,
459        }
460    }
461}
462
463fn metadata_i32_or_default(value: SubtrActorResult<i32>) -> i32 {
464    value.unwrap_or(0)
465}
466
467#[cfg(test)]
468#[path = "replay_data_tests.rs"]
469mod replay_data_tests;
470
471/// Contains all frame-by-frame data for a Rocket League replay.
472///
473/// This structure organizes ball data, player data, and metadata for each
474/// frame of the replay, providing a complete picture of the game state
475/// throughout the match.
476///
477/// # Fields
478///
479/// * `ball_data` - All ball state information across all frames
480/// * `players` - Player data for each player, indexed by [`PlayerId`]
481/// * `metadata_frames` - Game metadata for each frame including timing information
482#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
483#[ts(export)]
484pub struct FrameData {
485    /// All ball state information across all frames
486    pub ball_data: BallData,
487    /// Player data for each player, indexed by PlayerId
488    #[ts(as = "Vec<(crate::interop::ts_bindings::RemoteIdTs, PlayerData)>")]
489    pub players: Vec<(PlayerId, PlayerData)>,
490    /// Game metadata for each frame including timing information
491    pub metadata_frames: Vec<MetadataFrame>,
492}
493
494/// Complete replay data structure containing all extracted information from a Rocket League replay.
495///
496/// This is the top-level structure that contains all processed replay data including
497/// frame-by-frame information, replay metadata, and special events like demolitions.
498///
499/// # Fields
500///
501/// * `frame_data` - All frame-by-frame data including ball, player, and metadata information
502/// * `meta` - Replay metadata including player information, game settings, and statistics
503/// * `demolish_infos` - Information about all demolition events that occurred during the replay
504/// * `boost_pad_events` - Exact boost pad pickup/availability events detected while processing
505/// * `boost_pads` - Resolved standard boost pad layout annotated with replay pad ids when known
506/// * `touch_events` - Replay-authored team touch markers; player attribution is derived by stats
507/// * `dodge_refreshed_events` - Exact counter-derived dodge refresh events from the replay
508/// * `player_stat_events` - Exact shot/save/assist counter increment events
509/// * `goal_events` - Exact goal explosion events with scorer and cumulative score when available
510/// * `replay_tick_marks` - Replay-authored timeline tick marks/bookmarks
511///
512/// # Example
513///
514/// ```rust
515/// use subtr_actor::collector::replay_data::ReplayDataCollector;
516/// use boxcars::ParserBuilder;
517///
518/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
519/// let replay = ParserBuilder::new(&data).parse().unwrap();
520/// let collector = ReplayDataCollector::new();
521/// let replay_data = collector.get_replay_data(&replay).unwrap();
522///
523/// // Access replay metadata
524/// println!("Team 0 players: {}", replay_data.meta.team_zero.len());
525///
526/// // Access frame data
527/// println!("Total frames: {}", replay_data.frame_data.metadata_frames.len());
528///
529/// // Access demolition events
530/// println!("Total demolitions: {}", replay_data.demolish_infos.len());
531/// ```
532#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
533#[ts(export)]
534pub struct ReplayData {
535    /// All frame-by-frame data including ball, player, and metadata information
536    pub frame_data: FrameData,
537    /// Replay metadata including player information, game settings, and statistics
538    pub meta: ReplayMeta,
539    /// Information about all demolition events that occurred during the replay
540    pub demolish_infos: Vec<DemolishInfo>,
541    /// Exact boost pad pickup and availability events observed during the replay
542    pub boost_pad_events: Vec<BoostPadEvent>,
543    /// Resolved standard boost pad layout annotated with replay pad ids when known
544    pub boost_pads: Vec<ResolvedBoostPad>,
545    /// Replay-authored team touch markers observed during the replay
546    pub touch_events: Vec<TouchEvent>,
547    /// Exact dodge refresh events observed via the replay's refreshed-dodge counter
548    pub dodge_refreshed_events: Vec<DodgeRefreshedEvent>,
549    /// Exact player stat counter increments observed during the replay
550    pub player_stat_events: Vec<PlayerStatEvent>,
551    /// Exact goal events observed during the replay
552    pub goal_events: Vec<GoalEvent>,
553    /// Replay-authored tick marks/bookmarks from the replay body
554    pub replay_tick_marks: Vec<ReplayTickMark>,
555}
556
557impl ReplayData {
558    /// Serializes the replay data to a JSON string.
559    ///
560    /// # Returns
561    ///
562    /// Returns a [`Result`] containing either the JSON string representation
563    /// of the replay data or a [`serde_json::Error`] if serialization fails.
564    ///
565    /// # Example
566    ///
567    /// ```rust
568    /// use subtr_actor::collector::replay_data::ReplayDataCollector;
569    /// use boxcars::ParserBuilder;
570    ///
571    /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
572    /// let replay = ParserBuilder::new(&data).parse().unwrap();
573    /// let collector = ReplayDataCollector::new();
574    /// let replay_data = collector.get_replay_data(&replay).unwrap();
575    ///
576    /// let json_string = replay_data.as_json().unwrap();
577    /// println!("Replay as JSON: {}", json_string);
578    /// ```
579    pub fn as_json(&self) -> Result<String, serde_json::Error> {
580        serde_json::to_string(self)
581    }
582
583    /// Serializes the replay data to a pretty-printed JSON string.
584    ///
585    /// # Returns
586    ///
587    /// Returns a [`Result`] containing either the pretty-printed JSON string
588    /// representation of the replay data or a [`serde_json::Error`] if serialization fails.
589    pub fn as_pretty_json(&self) -> Result<String, serde_json::Error> {
590        serde_json::to_string_pretty(self)
591    }
592}
593
594fn replay_tick_marks(
595    replay: &boxcars::Replay,
596    metadata_frames: &[MetadataFrame],
597) -> Vec<ReplayTickMark> {
598    replay
599        .tick_marks
600        .iter()
601        .map(|tick_mark| ReplayTickMark {
602            description: tick_mark.description.clone(),
603            frame: tick_mark.frame,
604            time: usize::try_from(tick_mark.frame)
605                .ok()
606                .and_then(|frame| metadata_frames.get(frame))
607                .map(|frame| frame.time),
608        })
609        .collect()
610}
611
612pub(crate) fn player_stat_events_with_shot_saves(
613    player_stat_events: &[PlayerStatEvent],
614) -> Vec<PlayerStatEvent> {
615    let mut annotated_events = player_stat_events.to_vec();
616    let mut pending_shot_indices = Vec::new();
617
618    for index in 0..annotated_events.len() {
619        match annotated_events[index].kind {
620            PlayerStatEventKind::Shot => {
621                if annotated_events[index].shot.is_some() {
622                    pending_shot_indices.push(index);
623                }
624            }
625            PlayerStatEventKind::Save => {
626                let save = ShotSaveMetadata {
627                    time: annotated_events[index].time,
628                    frame: annotated_events[index].frame,
629                    player: annotated_events[index].player.clone(),
630                    player_position: annotated_events[index].player_position,
631                    is_team_0: annotated_events[index].is_team_0,
632                };
633                let Some(pending_position) = pending_shot_indices.iter().rposition(|shot_index| {
634                    annotated_events[*shot_index].is_team_0 != annotated_events[index].is_team_0
635                }) else {
636                    continue;
637                };
638                let shot_index = pending_shot_indices.remove(pending_position);
639                if let Some(shot) = annotated_events[shot_index].shot.as_mut() {
640                    shot.resulting_save = Some(save);
641                }
642            }
643            PlayerStatEventKind::Assist => {}
644        }
645    }
646
647    annotated_events
648}
649
650impl FrameData {
651    /// Creates a new empty [`FrameData`] instance.
652    ///
653    /// # Returns
654    ///
655    /// Returns a new [`FrameData`] with empty ball data, player data, and metadata frames.
656    fn new() -> Self {
657        FrameData {
658            ball_data: BallData::new(),
659            players: Vec::new(),
660            metadata_frames: Vec::new(),
661        }
662    }
663
664    /// Returns the total number of frames in this frame data.
665    ///
666    /// # Returns
667    ///
668    /// Returns the number of metadata frames, which represents the total frame count.
669    pub fn frame_count(&self) -> usize {
670        self.metadata_frames.len()
671    }
672
673    /// Returns the duration of the replay in seconds.
674    ///
675    /// # Returns
676    ///
677    /// Returns the time of the last frame, or 0.0 if no frames exist.
678    pub fn duration(&self) -> f32 {
679        self.metadata_frames.last().map(|f| f.time).unwrap_or(0.0)
680    }
681
682    /// Adds a complete frame of data to the frame data structure.
683    ///
684    /// This method adds metadata, ball data, and player data for a single frame
685    /// to their respective collections, maintaining frame synchronization across
686    /// all data types.
687    ///
688    /// # Arguments
689    ///
690    /// * `frame_metadata` - The metadata for this frame (time, game state, etc.)
691    /// * `ball_frame` - The ball state for this frame
692    /// * `player_frames` - Player state data for all players in this frame
693    ///
694    /// # Returns
695    ///
696    /// Returns a [`SubtrActorResult`] indicating success or failure of the operation.
697    ///
698    /// # Errors
699    ///
700    /// May return a [`SubtrActorError`] if frame data cannot be processed correctly.
701    fn add_frame(
702        &mut self,
703        frame_metadata: MetadataFrame,
704        ball_frame: BallFrame,
705        player_frames: Vec<(PlayerId, PlayerFrame)>,
706    ) -> SubtrActorResult<()> {
707        let frame_index = self.metadata_frames.len();
708        self.metadata_frames.push(frame_metadata);
709        self.ball_data.add_frame(frame_index, ball_frame);
710        for (player_id, frame) in player_frames {
711            self.players
712                .get_entry(player_id)
713                .or_insert_with(PlayerData::new)
714                .add_frame(frame_index, frame)
715        }
716        Ok(())
717    }
718}
719
720/// A collector that extracts comprehensive frame-by-frame data from Rocket League replays.
721///
722/// [`ReplayDataCollector`] implements the [`Collector`] trait to process replay frames
723/// and extract detailed information about ball movement, player actions, and game state.
724/// It builds a complete [`ReplayData`] structure containing all available information
725/// from the replay.
726///
727/// # Usage
728///
729/// The collector is designed to be used with the [`ReplayProcessor`] to extract
730/// comprehensive replay data:
731///
732/// ```rust
733/// use subtr_actor::collector::replay_data::ReplayDataCollector;
734/// use boxcars::ParserBuilder;
735///
736/// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
737/// let replay = ParserBuilder::new(&data).parse().unwrap();
738///
739/// let collector = ReplayDataCollector::new();
740/// let replay_data = collector.get_replay_data(&replay).unwrap();
741///
742/// // Process the extracted data
743/// for (frame_idx, metadata) in replay_data.frame_data.metadata_frames.iter().enumerate() {
744///     println!("Frame {}: Time={:.2}s, Remaining={}s",
745///              frame_idx, metadata.time, metadata.seconds_remaining);
746/// }
747/// ```
748///
749/// # Fields
750///
751/// * `frame_data` - Internal storage for frame-by-frame data during collection
752pub struct ReplayDataCollector {
753    /// Internal storage for frame-by-frame data during collection
754    frame_data: FrameData,
755}
756
757impl Default for ReplayDataCollector {
758    /// Creates a default [`ReplayDataCollector`] instance.
759    ///
760    /// This is equivalent to calling [`ReplayDataCollector::new()`].
761    fn default() -> Self {
762        Self::new()
763    }
764}
765
766impl ReplayDataCollector {
767    /// Creates a new [`ReplayDataCollector`] instance.
768    ///
769    /// # Returns
770    ///
771    /// Returns a new collector ready to process replay frames.
772    pub fn new() -> Self {
773        ReplayDataCollector {
774            frame_data: FrameData::new(),
775        }
776    }
777
778    /// Consumes the collector and returns the collected frame data.
779    ///
780    /// # Returns
781    ///
782    /// Returns the [`FrameData`] containing all processed frame information.
783    pub fn get_frame_data(self) -> FrameData {
784        self.frame_data
785    }
786
787    pub fn into_replay_data(self, processor: ReplayProcessor<'_>) -> SubtrActorResult<ReplayData> {
788        let meta = processor.get_replay_meta()?;
789        let frame_data = self.get_frame_data();
790        Ok(ReplayData {
791            meta,
792            demolish_infos: processor.demolishes().to_vec(),
793            boost_pad_events: processor.boost_pad_events().to_vec(),
794            boost_pads: processor.resolved_boost_pads(),
795            touch_events: processor.touch_events().to_vec(),
796            dodge_refreshed_events: processor.dodge_refreshed_events().to_vec(),
797            player_stat_events: player_stat_events_with_shot_saves(processor.player_stat_events()),
798            goal_events: processor.goal_events().to_vec(),
799            replay_tick_marks: replay_tick_marks(processor.replay, &frame_data.metadata_frames),
800            frame_data,
801        })
802    }
803
804    /// Processes a replay and returns complete replay data.
805    ///
806    /// This method processes the entire replay using a [`ReplayProcessor`] and
807    /// extracts all available information including frame-by-frame data, metadata,
808    /// and special events like demolitions.
809    ///
810    /// # Arguments
811    ///
812    /// * `replay` - The parsed replay data from the [`boxcars`] library
813    ///
814    /// # Returns
815    ///
816    /// Returns a [`SubtrActorResult`] containing the complete [`ReplayData`] structure
817    /// with all extracted information.
818    ///
819    /// # Errors
820    ///
821    /// Returns a [`SubtrActorError`] if:
822    /// - The replay processor cannot be created
823    /// - Frame processing fails
824    /// - Replay metadata cannot be extracted
825    ///
826    /// # Example
827    ///
828    /// ```rust
829    /// use subtr_actor::collector::replay_data::ReplayDataCollector;
830    /// use boxcars::ParserBuilder;
831    ///
832    /// let data = std::fs::read("assets/replay-format-2025-06-10-v868-32-net10-replicated-boost.replay").unwrap();
833    /// let replay = ParserBuilder::new(&data).parse().unwrap();
834    ///
835    /// let collector = ReplayDataCollector::new();
836    /// let replay_data = collector.get_replay_data(&replay).unwrap();
837    ///
838    /// println!("Processed {} frames", replay_data.frame_data.frame_count());
839    /// ```
840    pub fn get_replay_data(mut self, replay: &boxcars::Replay) -> SubtrActorResult<ReplayData> {
841        let mut processor = ReplayProcessor::new(replay)?;
842        processor.process_all(&mut [&mut self])?;
843        self.into_replay_data(processor)
844    }
845
846    /// Extracts player frame data for all players at the specified time.
847    ///
848    /// This method iterates through all players in the replay and extracts their
849    /// state information at the given time, returning a vector of player frames
850    /// indexed by player ID.
851    ///
852    /// # Arguments
853    ///
854    /// * `processor` - The [`ReplayProcessor`] containing the replay data
855    /// * `current_time` - The time in seconds at which to extract player states
856    ///
857    /// # Returns
858    ///
859    /// Returns a [`SubtrActorResult`] containing a vector of tuples with player IDs
860    /// and their corresponding [`PlayerFrame`] data.
861    ///
862    /// # Errors
863    ///
864    /// Returns a [`SubtrActorError`] if player frame data cannot be extracted.
865    fn get_player_frames(
866        &self,
867        processor: &dyn ProcessorView,
868        current_time: f32,
869    ) -> SubtrActorResult<Vec<(PlayerId, PlayerFrame)>> {
870        Ok(processor
871            .iter_player_ids_in_order()
872            .map(|player_id| {
873                (
874                    player_id.clone(),
875                    PlayerFrame::new_from_processor(processor, player_id, current_time)
876                        .unwrap_or(PlayerFrame::Empty),
877                )
878            })
879            .collect())
880    }
881}
882
883impl Collector for ReplayDataCollector {
884    /// Processes a single frame of the replay and extracts all relevant data.
885    ///
886    /// This method is called by the [`ReplayProcessor`] for each frame in the replay.
887    /// It extracts metadata, ball state, and player state information and adds them
888    /// to the internal frame data structure.
889    ///
890    /// # Arguments
891    ///
892    /// * `processor` - The [`ReplayProcessor`] containing the replay data and context
893    /// * `_frame` - The current frame data (unused in this implementation)
894    /// * `_frame_number` - The current frame number (unused in this implementation)
895    /// * `current_time` - The current time in seconds since the start of the replay
896    ///
897    /// # Returns
898    ///
899    /// Returns a [`SubtrActorResult`] containing [`TimeAdvance::NextFrame`] to
900    /// indicate that processing should continue to the next frame.
901    ///
902    /// # Errors
903    ///
904    /// Returns a [`SubtrActorError`] if:
905    /// - Metadata frame cannot be created
906    /// - Player frame data cannot be extracted
907    /// - Frame data cannot be added to the collection
908    fn process_frame(
909        &mut self,
910        processor: &dyn ProcessorView,
911        _frame: &boxcars::Frame,
912        _frame_number: usize,
913        current_time: f32,
914    ) -> SubtrActorResult<TimeAdvance> {
915        let metadata_frame = MetadataFrame::new_from_processor(processor, current_time)?;
916        let ball_frame = BallFrame::new_from_processor(processor, current_time);
917        let player_frames = self.get_player_frames(processor, current_time)?;
918        self.frame_data
919            .add_frame(metadata_frame, ball_frame, player_frames)?;
920        Ok(TimeAdvance::NextFrame)
921    }
922}