Skip to main content

subtr_actor/domain/
replay_model.rs

1//! Higher-level, serde-friendly game-state model derived from the raw replay.
2//!
3//! These types are the structured shape that collectors (notably
4//! [`ReplayDataCollector`](crate::ReplayDataCollector)) emit for JSON export and
5//! playback UIs — players, frames, rigid bodies, and the derived event streams,
6//! one level up from `boxcars`' raw actor data. Replay-level metadata lives in
7//! [`replay_meta`](crate::replay_meta).
8
9use boxcars::{HeaderProp, RemoteId};
10use serde::Serialize;
11
12use crate::{
13    BallBounceConfig, BallCollisionSurface, BallGoalLineCrossingConfig, BallGoalTargetHit,
14    BallGoalTargetHitKind, BallTrajectoryConfig, STANDARD_BALL_RADIUS,
15    STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN, glam_to_vec,
16    predict_ball_with_surface_bounces_goal_line_crossing,
17    predict_ball_with_surface_bounces_goal_target_hit, predict_free_flight_goal_line_crossing,
18    standard_soccar_goal_line_prediction_field_surfaces,
19    standard_soccar_goal_line_prediction_surfaces, standard_soccar_goal_target_prediction_surfaces,
20    vec_to_glam,
21};
22
23pub type PlayerId = boxcars::RemoteId;
24
25/// Represents which demolition format a replay uses.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DemolishFormat {
28    /// Old format (pre-September 2024): uses `ReplicatedDemolishGoalExplosion`
29    Fx,
30    /// New format (September 2024+): uses `ReplicatedDemolishExtended`
31    Extended,
32}
33
34/// Wrapper enum for different demolition attribute formats across Rocket League versions.
35///
36/// Rocket League changed the demolition data structure around September 2024 (v2.43+),
37/// moving from `DemolishFx` to `DemolishExtended`. This enum provides a unified interface
38/// for both formats.
39#[derive(Debug, Clone, PartialEq)]
40pub enum DemolishAttribute {
41    Fx(boxcars::DemolishFx),
42    Extended(boxcars::DemolishExtended),
43}
44
45impl DemolishAttribute {
46    pub fn attacker_actor_id(&self) -> boxcars::ActorId {
47        match self {
48            DemolishAttribute::Fx(fx) => fx.attacker,
49            DemolishAttribute::Extended(ext) => ext.attacker.actor,
50        }
51    }
52
53    pub fn victim_actor_id(&self) -> boxcars::ActorId {
54        match self {
55            DemolishAttribute::Fx(fx) => fx.victim,
56            DemolishAttribute::Extended(ext) => ext.victim.actor,
57        }
58    }
59
60    pub fn attacker_velocity(&self) -> boxcars::Vector3f {
61        match self {
62            DemolishAttribute::Fx(fx) => fx.attack_velocity,
63            DemolishAttribute::Extended(ext) => ext.attacker_velocity,
64        }
65    }
66
67    pub fn victim_velocity(&self) -> boxcars::Vector3f {
68        match self {
69            DemolishAttribute::Fx(fx) => fx.victim_velocity,
70            DemolishAttribute::Extended(ext) => ext.victim_velocity,
71        }
72    }
73}
74
75/// [`DemolishInfo`] struct represents data related to a demolition event in the game.
76///
77/// Demolition events occur when one player 'demolishes' or 'destroys' another by
78/// hitting them at a sufficiently high speed. This results in the demolished player
79/// being temporarily removed from play.
80#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
81#[ts(export)]
82pub struct DemolishInfo {
83    /// The exact game time (in seconds) at which the demolition event occurred.
84    pub time: f32,
85    /// The remaining time in the match when the demolition event occurred.
86    pub seconds_remaining: i32,
87    /// The frame number at which the demolition occurred.
88    pub frame: usize,
89    /// The [`PlayerId`] of the player who initiated the demolition.
90    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
91    pub attacker: PlayerId,
92    /// The [`PlayerId`] of the player who was demolished.
93    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
94    pub victim: PlayerId,
95    /// The velocity of the attacker at the time of demolition.
96    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
97    pub attacker_velocity: boxcars::Vector3f,
98    /// The velocity of the victim at the time of demolition.
99    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
100    pub victim_velocity: boxcars::Vector3f,
101    /// The location of the attacker at the time of demolition.
102    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub attacker_location: Option<boxcars::Vector3f>,
105    /// The location of the victim at the time of demolition.
106    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
107    pub victim_location: boxcars::Vector3f,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
111#[ts(export)]
112pub enum BoostPadEventKind {
113    PickedUp { sequence: u8 },
114    Available,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
118#[ts(export)]
119pub enum BoostPadSize {
120    Big,
121    Small,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
125#[ts(export)]
126pub struct BoostPadEvent {
127    pub time: f32,
128    pub frame: usize,
129    pub pad_id: String,
130    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
131    pub player: Option<PlayerId>,
132    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub player_position: Option<boxcars::Vector3f>,
135    pub kind: BoostPadEventKind,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
139#[ts(export)]
140pub struct ResolvedBoostPad {
141    pub index: usize,
142    pub pad_id: Option<String>,
143    pub size: BoostPadSize,
144    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
145    pub position: boxcars::Vector3f,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
149#[ts(export)]
150pub struct GoalEvent {
151    pub time: f32,
152    pub frame: usize,
153    pub scoring_team_is_team_0: bool,
154    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
155    pub player: Option<PlayerId>,
156    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub player_position: Option<boxcars::Vector3f>,
159    pub team_zero_score: Option<i32>,
160    pub team_one_score: Option<i32>,
161}
162
163/// A replay tick mark stored in the replay file.
164///
165/// Rocket League/Boxcars use tick marks for replay timeline annotations such as
166/// goal markers and other saved replay highlights. The frame is preserved from
167/// the replay body; `time` is resolved from collected frame metadata when that
168/// frame is present in the processed replay.
169#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
170#[ts(export)]
171pub struct ReplayTickMark {
172    pub description: String,
173    pub frame: i32,
174    pub time: Option<f32>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
178#[ts(export)]
179pub enum PlayerStatEventKind {
180    Shot,
181    Save,
182    Assist,
183}
184
185const SHOT_TARGET_GOAL_CENTER_Y: f32 = 5120.0;
186
187#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
188#[ts(export)]
189pub struct ShotSaveMetadata {
190    pub time: f32,
191    pub frame: usize,
192    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
193    pub player: PlayerId,
194    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub player_position: Option<boxcars::Vector3f>,
197    pub is_team_0: bool,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
201#[ts(export)]
202#[serde(rename_all = "snake_case")]
203pub enum ShotGoalLineCrossingPredictionKind {
204    SurfaceBounces,
205    FreeFlight,
206    SavedShotPreSaveSurfaceBounces,
207    SavedShotPreSaveFreeFlight,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
211#[ts(export)]
212#[serde(rename_all = "snake_case")]
213pub enum ShotGoalLineCrossingUnavailableReason {
214    NoBallVelocity,
215    NoGoalwardBallBeforeSaveReference,
216    NoGoalLineCrossingBeforeSaveReference,
217    OnlyUnphysicalFreeFlightCrossings,
218    CrossingsBeforePredictionStart,
219    CrossingsBeforeSaveTouch,
220    CrossingsBeforeSaveStat,
221    NoUsableProjection,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
225#[ts(export)]
226#[serde(rename_all = "snake_case")]
227pub enum ShotGoalTargetHitKind {
228    GoalLine,
229    BackWall,
230    GoalFrame,
231}
232
233impl From<BallGoalTargetHitKind> for ShotGoalTargetHitKind {
234    fn from(value: BallGoalTargetHitKind) -> Self {
235        match value {
236            BallGoalTargetHitKind::GoalLine => Self::GoalLine,
237            BallGoalTargetHitKind::BackWall => Self::BackWall,
238            BallGoalTargetHitKind::GoalFrame => Self::GoalFrame,
239        }
240    }
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
244#[ts(export)]
245pub struct ShotGoalLineCrossing {
246    /// Seconds after the shot touch when the ball is projected to cross the
247    /// target goal line.
248    pub time_after_shot: f32,
249    /// Absolute replay time used as the prediction's shot-touch reference.
250    ///
251    /// When absent, consumers should use the owning [`PlayerStatEvent`]'s time.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub prediction_start_time: Option<f32>,
254    /// Replay frame used as the prediction's shot-touch reference.
255    ///
256    /// When absent, consumers should use the owning [`PlayerStatEvent`]'s frame.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub prediction_start_frame: Option<usize>,
259    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
260    pub position: boxcars::Vector3f,
261    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
262    pub velocity: Option<boxcars::Vector3f>,
263    pub inside_goal_mouth: bool,
264    pub prediction_kind: ShotGoalLineCrossingPredictionKind,
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
268#[ts(export)]
269pub struct ShotGoalTargetHit {
270    /// Seconds after the shot touch when the ball is projected to reach the
271    /// goal-line plane, attacking back wall, or goal frame.
272    pub time_after_shot: f32,
273    /// Absolute replay time used as the prediction's shot-touch reference.
274    ///
275    /// When absent, consumers should use the owning [`PlayerStatEvent`]'s time.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub prediction_start_time: Option<f32>,
278    /// Replay frame used as the prediction's shot-touch reference.
279    ///
280    /// When absent, consumers should use the owning [`PlayerStatEvent`]'s frame.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub prediction_start_frame: Option<usize>,
283    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
284    pub position: boxcars::Vector3f,
285    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
286    pub velocity: Option<boxcars::Vector3f>,
287    pub hit_kind: ShotGoalTargetHitKind,
288}
289
290impl ShotGoalTargetHit {
291    pub(crate) fn predict_from_rigid_body(
292        is_team_0: bool,
293        ball_body: &boxcars::RigidBody,
294    ) -> Option<Self> {
295        predict_ball_with_surface_bounces_goal_target_hit(
296            ball_body,
297            BallGoalLineCrossingConfig::attacking_goal(is_team_0),
298            BallTrajectoryConfig::STANDARD_SOCCAR,
299            BallBounceConfig::STANDARD_SOCCAR,
300            &standard_soccar_goal_target_prediction_surfaces(),
301        )
302        .map(Self::from_ball_goal_target_hit)
303    }
304
305    fn from_ball_goal_target_hit(hit: BallGoalTargetHit) -> Self {
306        Self {
307            time_after_shot: hit.time,
308            prediction_start_time: None,
309            prediction_start_frame: None,
310            position: glam_to_vec(&hit.position),
311            velocity: hit.velocity.map(|velocity| glam_to_vec(&velocity)),
312            hit_kind: hit.hit_kind.into(),
313        }
314    }
315
316    pub(crate) fn from_goal_line_crossing(crossing: &ShotGoalLineCrossing) -> Self {
317        Self {
318            time_after_shot: crossing.time_after_shot,
319            prediction_start_time: crossing.prediction_start_time,
320            prediction_start_frame: crossing.prediction_start_frame,
321            position: crossing.position,
322            velocity: crossing.velocity,
323            hit_kind: if crossing.inside_goal_mouth {
324                ShotGoalTargetHitKind::GoalLine
325            } else {
326                ShotGoalTargetHitKind::BackWall
327            },
328        }
329    }
330}
331
332impl ShotGoalLineCrossing {
333    pub(crate) fn predict_from_rigid_body(
334        is_team_0: bool,
335        ball_body: &boxcars::RigidBody,
336    ) -> Option<Self> {
337        Self::predict_from_rigid_body_with_kinds(
338            is_team_0,
339            ball_body,
340            ShotGoalLineCrossingPredictionKind::SurfaceBounces,
341            ShotGoalLineCrossingPredictionKind::FreeFlight,
342            false,
343            &standard_soccar_goal_line_prediction_surfaces(),
344        )
345    }
346
347    pub(crate) fn predict_saved_shot_from_rigid_body(
348        is_team_0: bool,
349        ball_body: &boxcars::RigidBody,
350    ) -> Option<Self> {
351        Self::predict_from_rigid_body_with_kinds(
352            is_team_0,
353            ball_body,
354            ShotGoalLineCrossingPredictionKind::SavedShotPreSaveSurfaceBounces,
355            ShotGoalLineCrossingPredictionKind::SavedShotPreSaveFreeFlight,
356            true,
357            &standard_soccar_goal_line_prediction_field_surfaces(),
358        )
359    }
360
361    fn predict_from_rigid_body_with_kinds(
362        is_team_0: bool,
363        ball_body: &boxcars::RigidBody,
364        surface_prediction_kind: ShotGoalLineCrossingPredictionKind,
365        free_flight_prediction_kind: ShotGoalLineCrossingPredictionKind,
366        reject_unphysical_free_flight: bool,
367        goal_line_prediction_surfaces: &[BallCollisionSurface],
368    ) -> Option<Self> {
369        let crossing_config = BallGoalLineCrossingConfig::attacking_goal(is_team_0);
370        predict_ball_with_surface_bounces_goal_line_crossing(
371            ball_body,
372            crossing_config,
373            BallTrajectoryConfig::STANDARD_SOCCAR,
374            BallBounceConfig::STANDARD_SOCCAR,
375            goal_line_prediction_surfaces,
376        )
377        .map(|crossing| (crossing, surface_prediction_kind))
378        .or_else(|| {
379            predict_free_flight_goal_line_crossing(
380                ball_body,
381                crossing_config,
382                BallTrajectoryConfig::STANDARD_SOCCAR,
383            )
384            .filter(|crossing| {
385                !reject_unphysical_free_flight
386                    || saved_shot_free_flight_crossing_is_physically_plausible(crossing)
387            })
388            .map(|crossing| (crossing, free_flight_prediction_kind))
389        })
390        .map(|(crossing, prediction_kind)| ShotGoalLineCrossing {
391            time_after_shot: crossing.time,
392            prediction_start_time: None,
393            prediction_start_frame: None,
394            position: glam_to_vec(&crossing.position),
395            velocity: crossing.velocity.map(|velocity| glam_to_vec(&velocity)),
396            inside_goal_mouth: crossing.inside_goal_mouth,
397            prediction_kind,
398        })
399    }
400}
401
402fn saved_shot_free_flight_crossing_is_physically_plausible(
403    crossing: &crate::BallGoalLineCrossing,
404) -> bool {
405    crossing.position.z >= STANDARD_BALL_RADIUS - STANDARD_GOAL_MOUTH_TRAJECTORY_MARGIN
406}
407
408#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
409#[ts(export)]
410pub struct ShotEventMetadata {
411    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
412    pub shot_touch_position: boxcars::Vector3f,
413    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
414    pub ball_position: boxcars::Vector3f,
415    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
416    pub ball_velocity: Option<boxcars::Vector3f>,
417    pub ball_speed: Option<f32>,
418    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
419    pub player_position: Option<boxcars::Vector3f>,
420    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
421    pub player_velocity: Option<boxcars::Vector3f>,
422    pub player_speed: Option<f32>,
423    pub player_distance_to_ball: Option<f32>,
424    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
425    pub target_goal_position: boxcars::Vector3f,
426    pub distance_to_goal_center: f32,
427    pub distance_to_goal_line: f32,
428    pub ball_goal_alignment: Option<f32>,
429    pub ball_speed_toward_goal: Option<f32>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub projected_goal_line_crossing: Option<ShotGoalLineCrossing>,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub projected_goal_line_crossing_unavailable_reason:
434        Option<ShotGoalLineCrossingUnavailableReason>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub projected_goal_target_hit: Option<ShotGoalTargetHit>,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub resulting_save: Option<ShotSaveMetadata>,
439}
440
441impl ShotEventMetadata {
442    pub fn from_rigid_bodies(
443        is_team_0: bool,
444        ball_body: &boxcars::RigidBody,
445        player_body: Option<&boxcars::RigidBody>,
446    ) -> Self {
447        let ball_position = vec_to_glam(&ball_body.location);
448        let ball_velocity = ball_body.linear_velocity.as_ref().map(vec_to_glam);
449        let player_position = player_body.map(|body| vec_to_glam(&body.location));
450        let player_velocity =
451            player_body.and_then(|body| body.linear_velocity.as_ref().map(vec_to_glam));
452        let target_goal_y = if is_team_0 {
453            SHOT_TARGET_GOAL_CENTER_Y
454        } else {
455            -SHOT_TARGET_GOAL_CENTER_Y
456        };
457        let target_goal_position = glam::Vec3::new(0.0, target_goal_y, ball_position.z);
458        let goal_vector = target_goal_position - ball_position;
459        let goal_direction = goal_vector.normalize_or_zero();
460        let forward_sign = if is_team_0 { 1.0 } else { -1.0 };
461        let distance_to_goal_line = ((target_goal_y - ball_position.y) * forward_sign).max(0.0);
462        let ball_goal_alignment = ball_velocity.map(|velocity| {
463            if velocity.length_squared() <= f32::EPSILON {
464                0.0
465            } else {
466                goal_direction.dot(velocity.normalize_or_zero())
467            }
468        });
469        let projected_goal_line_crossing =
470            ShotGoalLineCrossing::predict_from_rigid_body(is_team_0, ball_body);
471        let projected_goal_target_hit =
472            ShotGoalTargetHit::predict_from_rigid_body(is_team_0, ball_body);
473
474        Self {
475            shot_touch_position: ball_body.location,
476            ball_position: ball_body.location,
477            ball_velocity: ball_body.linear_velocity,
478            ball_speed: ball_velocity.map(|velocity| velocity.length()),
479            player_position: player_body.map(|body| body.location),
480            player_velocity: player_body.and_then(|body| body.linear_velocity),
481            player_speed: player_velocity.map(|velocity| velocity.length()),
482            player_distance_to_ball: player_position
483                .map(|position| (position - ball_position).length()),
484            target_goal_position: glam_to_vec(&target_goal_position),
485            distance_to_goal_center: goal_vector.length(),
486            distance_to_goal_line,
487            ball_goal_alignment,
488            ball_speed_toward_goal: ball_velocity.map(|velocity| goal_direction.dot(velocity)),
489            projected_goal_line_crossing,
490            projected_goal_line_crossing_unavailable_reason: None,
491            projected_goal_target_hit,
492            resulting_save: None,
493        }
494    }
495}
496
497#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
498#[ts(export)]
499pub struct PlayerStatEvent {
500    pub time: f32,
501    pub frame: usize,
502    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
503    pub player: PlayerId,
504    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub player_position: Option<boxcars::Vector3f>,
507    pub is_team_0: bool,
508    pub kind: PlayerStatEventKind,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub shot: Option<ShotEventMetadata>,
511}
512
513/// A coalesced change in a player's discrete camera/vehicle toggles.
514///
515/// Ball cam, behind-view, and the driving flag flip only a handful of times per
516/// match, so rather than storing a value on every [`PlayerFrame`](crate::PlayerFrame) these are
517/// emitted as one change per player whenever any of them flips. Each change
518/// carries the full discrete state from that frame onward, so a consumer
519/// resolves "ball cam at frame N" with a last-change-before-N lookup.
520///
521/// Changes are grouped by player on [`ReplayData`](crate::ReplayData) (so the
522/// player id is stored once, not per change) and ordered by frame within each
523/// player. A field is `None` when the replay never replicated it for that
524/// player; `time` and `is_team_0` are intentionally omitted because both are
525/// derivable from `frame` and the player id.
526#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
527#[ts(export)]
528pub struct PlayerCameraStateChange {
529    pub frame: usize,
530    /// Whether ball cam (secondary camera) is active from this frame onward.
531    pub ball_cam_active: Option<bool>,
532    /// Whether behind-view is active from this frame onward.
533    pub behind_view_active: Option<bool>,
534    /// Whether the car reports the driving flag from this frame onward.
535    pub driving: Option<bool>,
536}
537
538#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
539#[ts(export)]
540pub struct TouchEvent {
541    /// Stable identity for an attributed touch, assigned monotonically when the
542    /// stats pipeline confirms the touch. `None` for raw replay team markers,
543    /// which exist before attribution. Downstream events that reference a touch
544    /// carry this id so consumers can join exactly instead of matching on
545    /// player + frame.
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    #[ts(type = "number")]
548    pub touch_id: Option<u64>,
549    pub time: f32,
550    pub frame: usize,
551    pub team_is_team_0: bool,
552    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
553    pub player: Option<PlayerId>,
554    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub player_position: Option<boxcars::Vector3f>,
557    /// Ball-to-car hitbox contact gap in uu for attributed touches, when estimated.
558    ///
559    /// This field keeps its historical name for wire compatibility. A value of
560    /// `0.0` means the ball intersects or touches the oriented car hitbox after
561    /// subtracting the Rocket League ball collision radius.
562    pub closest_approach_distance: Option<f32>,
563    /// Ball center in the car's local hitbox coordinates at the attributed touch.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub contact_local_ball_position: Option<[f32; 3]>,
566    /// Closest point on the car hitbox to the ball center, in local hitbox coordinates.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub contact_local_hitbox_point: Option<[f32; 3]>,
569    /// Closest point on the car hitbox to the ball center, in field coordinates.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub contact_world_hitbox_point: Option<[f32; 3]>,
572    pub dodge_contact: bool,
573}
574
575impl TouchEvent {
576    pub(crate) fn timestamp_ordering(left: &Self, right: &Self) -> std::cmp::Ordering {
577        left.frame
578            .cmp(&right.frame)
579            .then_with(|| left.time.total_cmp(&right.time))
580    }
581}
582
583pub(crate) const TOUCH_RATE_LIMIT_SECONDS: f32 = 0.25;
584
585/// Normalized high-level match type inferred from replay headers and network data.
586#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
587#[ts(export)]
588pub enum ReplayGameType {
589    /// Public ranked matchmaking.
590    Ranked,
591    /// Public unranked/casual matchmaking.
592    Casual,
593    /// Private match.
594    Private,
595    /// Local/offline exhibition match.
596    Offline,
597    /// LAN match.
598    Lan,
599    /// Tournament match.
600    Tournament,
601    /// The replay did not expose enough recognized metadata to classify the game type.
602    #[default]
603    Unknown,
604}
605
606/// Raw and normalized game-type metadata for a replay.
607#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
608#[ts(export)]
609pub struct ReplayGameTypeDetails {
610    /// Easy-to-use normalized classification.
611    pub game_type: ReplayGameType,
612    /// Header `MatchType`, when present. Post-EAC online replays often only say `Online`.
613    pub header_match_type: Option<String>,
614    /// Network `ProjectX.GRI_X:ReplicatedGamePlaylist`, when present.
615    pub playlist_id: Option<i32>,
616    /// Network `TAGame.GameEvent_TA:MatchTypeClass`, resolved to its actor object name.
617    pub match_type_class: Option<String>,
618}
619
620impl ReplayGameTypeDetails {
621    pub fn from_headers(headers: &[(String, HeaderProp)]) -> Self {
622        let header_match_type = headers
623            .iter()
624            .find(|(key, _)| key == "MatchType")
625            .and_then(|(_, value)| value.as_string())
626            .map(ToOwned::to_owned);
627
628        Self::from_signals(header_match_type, None, None)
629    }
630
631    pub fn from_signals(
632        header_match_type: Option<String>,
633        playlist_id: Option<i32>,
634        match_type_class: Option<String>,
635    ) -> Self {
636        let game_type = infer_replay_game_type(
637            header_match_type.as_deref(),
638            playlist_id,
639            match_type_class.as_deref(),
640        );
641        Self {
642            game_type,
643            header_match_type,
644            playlist_id,
645            match_type_class,
646        }
647    }
648
649    pub fn with_network_signals(
650        &self,
651        playlist_id: Option<i32>,
652        match_type_class: Option<String>,
653    ) -> Self {
654        Self::from_signals(
655            self.header_match_type.clone(),
656            playlist_id.or(self.playlist_id),
657            match_type_class.or_else(|| self.match_type_class.clone()),
658        )
659    }
660}
661
662fn infer_replay_game_type(
663    header_match_type: Option<&str>,
664    playlist_id: Option<i32>,
665    match_type_class: Option<&str>,
666) -> ReplayGameType {
667    if let Some(game_type) = match_type_class.and_then(replay_game_type_from_match_type_class) {
668        return game_type;
669    }
670    if let Some(game_type) = header_match_type.and_then(replay_game_type_from_header_match_type) {
671        return game_type;
672    }
673    if let Some(game_type) = playlist_id.and_then(replay_game_type_from_playlist_id) {
674        return game_type;
675    }
676    ReplayGameType::Unknown
677}
678
679fn replay_game_type_from_match_type_class(class_name: &str) -> Option<ReplayGameType> {
680    let normalized = class_name.to_ascii_lowercase();
681    if normalized.contains("publicranked") {
682        Some(ReplayGameType::Ranked)
683    } else if normalized.contains("private") {
684        Some(ReplayGameType::Private)
685    } else if normalized.contains("offline") {
686        Some(ReplayGameType::Offline)
687    } else if normalized.contains("lan") {
688        Some(ReplayGameType::Lan)
689    } else if normalized.contains("tournament") {
690        Some(ReplayGameType::Tournament)
691    } else if normalized.contains("public") {
692        Some(ReplayGameType::Casual)
693    } else {
694        None
695    }
696}
697
698fn replay_game_type_from_playlist_id(playlist_id: i32) -> Option<ReplayGameType> {
699    match playlist_id {
700        // Private and offline fixtures use these playlist ids, but LAN can also
701        // report 6, so header/class signals intentionally take precedence.
702        6 => Some(ReplayGameType::Private),
703        8 => Some(ReplayGameType::Offline),
704        // Unranked Duel, Doubles, Standard, and Chaos.
705        1..=4 => Some(ReplayGameType::Casual),
706        // Ranked Duel, Doubles, and Standard.
707        10 | 11 | 13 => Some(ReplayGameType::Ranked),
708        // Tournament-style fixtures observed across older and current replays.
709        22 | 34 => Some(ReplayGameType::Tournament),
710        // Older public playlist observed in the fixture corpus.
711        23 => Some(ReplayGameType::Casual),
712        // Ranked extra modes.
713        27..=30 => Some(ReplayGameType::Ranked),
714        _ => None,
715    }
716}
717
718fn replay_game_type_from_header_match_type(match_type: &str) -> Option<ReplayGameType> {
719    match match_type.to_ascii_lowercase().as_str() {
720        "ranked" => Some(ReplayGameType::Ranked),
721        "unranked" | "casual" => Some(ReplayGameType::Casual),
722        "private" => Some(ReplayGameType::Private),
723        "offline" => Some(ReplayGameType::Offline),
724        "lan" => Some(ReplayGameType::Lan),
725        "tournament" => Some(ReplayGameType::Tournament),
726        // Header-only `Online` is intentionally ambiguous.
727        "online" => None,
728        _ => None,
729    }
730}
731
732/// Which competitive-season numbering era a replay belongs to.
733///
734/// Rocket League restarted its season counter at 1 when it went free-to-play in
735/// September 2020, so the era is required to disambiguate (e.g. legacy Season 8
736/// vs free-to-play Season 8).
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
738#[ts(export)]
739pub enum SeasonEra {
740    /// Pre-free-to-play numbered competitive seasons (Season 1–14, 2016–2020).
741    Legacy,
742    /// Free-to-play seasons (Season 1 onward, from September 2020).
743    FreeToPlay,
744}
745
746impl SeasonEra {
747    /// Single-character code prefix used in the canonical season code.
748    fn code_prefix(self) -> char {
749        match self {
750            SeasonEra::Legacy => 's',
751            SeasonEra::FreeToPlay => 'f',
752        }
753    }
754}
755
756/// A resolved competitive season, identified by its numbering era and number.
757#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
758#[ts(export)]
759pub struct ReplaySeason {
760    /// Which numbering era the season belongs to.
761    pub era: SeasonEra,
762    /// Season number within its era (1-based).
763    pub number: u8,
764}
765
766impl ReplaySeason {
767    const fn new(era: SeasonEra, number: u8) -> Self {
768        Self { era, number }
769    }
770
771    /// Canonical short code, e.g. `f21` (free-to-play) or `s14` (legacy). Used as
772    /// the stable string key for storage, filtering, and display.
773    pub fn code(self) -> String {
774        format!("{}{}", self.era.code_prefix(), self.number)
775    }
776
777    /// The UTC instant this season went live, from `SEASON_BOUNDARIES`.
778    ///
779    /// Always `Some` for a season produced by resolution, since resolved seasons
780    /// come from the table; returns `None` only for a hand-constructed
781    /// [`ReplaySeason`] that has no boundary entry.
782    pub fn start(self) -> Option<SeasonStart> {
783        SEASON_BOUNDARIES
784            .iter()
785            .find(|(_, season)| *season == self)
786            .map(|(start, _)| *start)
787    }
788}
789
790/// The UTC instant a competitive season went live.
791///
792/// Stored per season so callers can display or reason about a boundary at finer
793/// than day precision. Season *resolution* still uses only the date (see
794/// `season_for_date`): the replay `Date` header is timezone-less local
795/// wall-clock, so its time-of-day cannot be meaningfully compared against a UTC
796/// instant. The time is therefore informational, and rough for older seasons
797/// (see `SEASON_BOUNDARIES`).
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
799#[ts(export)]
800pub struct SeasonStart {
801    /// UTC calendar year.
802    pub year: i32,
803    /// UTC month, 1-based.
804    pub month: u32,
805    /// UTC day of month, 1-based.
806    pub day: u32,
807    /// UTC hour, 0–23.
808    pub hour: u32,
809    /// UTC minute, 0–59.
810    pub minute: u32,
811}
812
813impl SeasonStart {
814    const fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> Self {
815        Self {
816            year,
817            month,
818            day,
819            hour,
820            minute,
821        }
822    }
823
824    /// The `(year, month, day)` UTC date portion, used for day-granular
825    /// resolution against the replay's local-wall-clock date.
826    const fn date(self) -> (i32, u32, u32) {
827        (self.year, self.month, self.day)
828    }
829
830    /// Full UTC datetime as a comparable tuple `(year, month, day, hour, minute)`.
831    const fn as_datetime_tuple(self) -> (i32, u32, u32, u32, u32) {
832        (self.year, self.month, self.day, self.hour, self.minute)
833    }
834}
835
836/// Competitive-season start dates, ascending by date.
837///
838/// Rocket League replays do not record the competitive season directly, so it is
839/// resolved from the recorded match `Date` against this table. Each entry records
840/// only the date a season *began*; a season runs until the next entry's start.
841/// Seasons are contiguous, so storing only the start (rather than start/end pairs)
842/// keeps the table impossible to leave with gaps or overlaps.
843///
844/// Each entry is the UTC instant the season went live (see [`SeasonStart`]).
845/// [`SeasonStart::new`] is `const` and the entries already order correctly, so
846/// the table needs no parsing or lazy initialization.
847///
848/// Resolution itself is day-granular (see `season_for_date`): the replay
849/// `Date` header is local wall-clock with no timezone, so a replay recorded
850/// within a day of a boundary is inherently ambiguous and the stored time-of-day
851/// cannot be compared against it. The time is stored per season purely as data
852/// for callers that want it (display, reporting).
853///
854/// Time precision by source:
855/// - Free-to-play S3 and S18–S23: exact go-live time from the official
856///   Rocket League "Season N Live" patch notes (which the season megathreads on
857///   r/RocketLeague cite). These announce a time like "9 AM PT / 4 PM UTC".
858/// - All other seasons: the date is the launch date, but the time is the
859///   *approximate* standard ~9 AM Pacific launch converted to UTC (16:00 in
860///   PDT, 17:00 in PST). Treat the hour for these as rough.
861///
862/// TODO(season-dates): the legacy (pre-free-to-play) start dates are still
863/// best-effort and have not been cross-checked against patch notes.
864const SEASON_BOUNDARIES: &[(SeasonStart, ReplaySeason)] = &[
865    // Pre-free-to-play numbered competitive seasons. Times approximate (~9 AM PT).
866    (
867        SeasonStart::new(2016, 2, 10, 17, 0),
868        ReplaySeason::new(SeasonEra::Legacy, 1),
869    ),
870    (
871        SeasonStart::new(2016, 6, 20, 16, 0),
872        ReplaySeason::new(SeasonEra::Legacy, 2),
873    ),
874    (
875        SeasonStart::new(2016, 9, 8, 16, 0),
876        ReplaySeason::new(SeasonEra::Legacy, 3),
877    ),
878    (
879        SeasonStart::new(2017, 3, 22, 16, 0),
880        ReplaySeason::new(SeasonEra::Legacy, 4),
881    ),
882    (
883        SeasonStart::new(2017, 9, 13, 16, 0),
884        ReplaySeason::new(SeasonEra::Legacy, 5),
885    ),
886    (
887        SeasonStart::new(2018, 3, 7, 17, 0),
888        ReplaySeason::new(SeasonEra::Legacy, 6),
889    ),
890    (
891        SeasonStart::new(2018, 9, 25, 16, 0),
892        ReplaySeason::new(SeasonEra::Legacy, 7),
893    ),
894    (
895        SeasonStart::new(2019, 3, 27, 16, 0),
896        ReplaySeason::new(SeasonEra::Legacy, 8),
897    ),
898    (
899        SeasonStart::new(2019, 8, 22, 16, 0),
900        ReplaySeason::new(SeasonEra::Legacy, 9),
901    ),
902    (
903        SeasonStart::new(2019, 12, 4, 17, 0),
904        ReplaySeason::new(SeasonEra::Legacy, 10),
905    ),
906    (
907        SeasonStart::new(2020, 4, 8, 16, 0),
908        ReplaySeason::new(SeasonEra::Legacy, 11),
909    ),
910    (
911        SeasonStart::new(2020, 7, 8, 16, 0),
912        ReplaySeason::new(SeasonEra::Legacy, 12),
913    ),
914    // Free-to-play era. Times approximate (~9 AM PT) except where noted "verified".
915    (
916        SeasonStart::new(2020, 9, 23, 16, 0),
917        ReplaySeason::new(SeasonEra::FreeToPlay, 1),
918    ),
919    (
920        SeasonStart::new(2020, 12, 9, 17, 0),
921        ReplaySeason::new(SeasonEra::FreeToPlay, 2),
922    ),
923    (
924        SeasonStart::new(2021, 4, 7, 15, 0),
925        ReplaySeason::new(SeasonEra::FreeToPlay, 3),
926    ), // verified: 8 AM PDT
927    (
928        SeasonStart::new(2021, 8, 11, 16, 0),
929        ReplaySeason::new(SeasonEra::FreeToPlay, 4),
930    ),
931    (
932        SeasonStart::new(2021, 11, 17, 17, 0),
933        ReplaySeason::new(SeasonEra::FreeToPlay, 5),
934    ),
935    (
936        SeasonStart::new(2022, 3, 9, 17, 0),
937        ReplaySeason::new(SeasonEra::FreeToPlay, 6),
938    ),
939    (
940        SeasonStart::new(2022, 6, 15, 16, 0),
941        ReplaySeason::new(SeasonEra::FreeToPlay, 7),
942    ),
943    (
944        SeasonStart::new(2022, 9, 7, 16, 0),
945        ReplaySeason::new(SeasonEra::FreeToPlay, 8),
946    ),
947    (
948        SeasonStart::new(2022, 12, 7, 17, 0),
949        ReplaySeason::new(SeasonEra::FreeToPlay, 9),
950    ),
951    (
952        SeasonStart::new(2023, 3, 8, 17, 0),
953        ReplaySeason::new(SeasonEra::FreeToPlay, 10),
954    ),
955    (
956        SeasonStart::new(2023, 6, 7, 16, 0),
957        ReplaySeason::new(SeasonEra::FreeToPlay, 11),
958    ),
959    (
960        SeasonStart::new(2023, 9, 6, 16, 0),
961        ReplaySeason::new(SeasonEra::FreeToPlay, 12),
962    ),
963    (
964        SeasonStart::new(2023, 12, 6, 17, 0),
965        ReplaySeason::new(SeasonEra::FreeToPlay, 13),
966    ),
967    (
968        SeasonStart::new(2024, 3, 6, 17, 0),
969        ReplaySeason::new(SeasonEra::FreeToPlay, 14),
970    ),
971    (
972        SeasonStart::new(2024, 6, 5, 16, 0),
973        ReplaySeason::new(SeasonEra::FreeToPlay, 15),
974    ),
975    (
976        SeasonStart::new(2024, 9, 4, 16, 0),
977        ReplaySeason::new(SeasonEra::FreeToPlay, 16),
978    ),
979    (
980        SeasonStart::new(2024, 12, 4, 17, 0),
981        ReplaySeason::new(SeasonEra::FreeToPlay, 17),
982    ),
983    (
984        SeasonStart::new(2025, 3, 14, 16, 0),
985        ReplaySeason::new(SeasonEra::FreeToPlay, 18),
986    ), // verified: 9 AM PDT
987    (
988        SeasonStart::new(2025, 6, 18, 15, 0),
989        ReplaySeason::new(SeasonEra::FreeToPlay, 19),
990    ), // verified: 8 AM PDT
991    (
992        SeasonStart::new(2025, 9, 17, 16, 0),
993        ReplaySeason::new(SeasonEra::FreeToPlay, 20),
994    ), // verified: 9 AM PDT
995    (
996        SeasonStart::new(2025, 12, 10, 17, 0),
997        ReplaySeason::new(SeasonEra::FreeToPlay, 21),
998    ), // verified: 9 AM PST
999    (
1000        SeasonStart::new(2026, 3, 11, 16, 0),
1001        ReplaySeason::new(SeasonEra::FreeToPlay, 22),
1002    ), // verified: 9 AM PDT
1003    (
1004        SeasonStart::new(2026, 6, 10, 16, 0),
1005        ReplaySeason::new(SeasonEra::FreeToPlay, 23),
1006    ), // verified: 9 AM PDT
1007];
1008
1009/// Resolves the competitive season from replay headers via the recorded match
1010/// date. Returns `None` when no usable date is present or the replay predates the
1011/// first known season.
1012pub fn season_from_headers(headers: &[(String, HeaderProp)]) -> Option<ReplaySeason> {
1013    headers
1014        .iter()
1015        .find(|(key, _)| {
1016            ["Date", "ReplayDate", "RecordDate"]
1017                .iter()
1018                .any(|name| key.eq_ignore_ascii_case(name))
1019        })
1020        .and_then(|(_, value)| value.as_string())
1021        .and_then(|s| {
1022            parse_header_datetime_utc(s)
1023                .and_then(season_for_datetime)
1024                .or_else(|| parse_header_date(s).and_then(season_for_date))
1025        })
1026}
1027
1028/// Returns the most recent season whose start is on or before `dt` (UTC).
1029fn season_for_datetime(dt: (i32, u32, u32, u32, u32)) -> Option<ReplaySeason> {
1030    SEASON_BOUNDARIES
1031        .iter()
1032        .rev()
1033        .find(|(start, _)| start.as_datetime_tuple() <= dt)
1034        .map(|(_, season)| *season)
1035}
1036
1037/// Returns the most recent season that began on or before `date`.
1038fn season_for_date(date: (i32, u32, u32)) -> Option<ReplaySeason> {
1039    SEASON_BOUNDARIES
1040        .iter()
1041        .rev()
1042        .find(|(start, _)| start.date() <= date)
1043        .map(|(_, season)| *season)
1044}
1045
1046/// Parses the replay `Date` header as a UTC `(year, month, day, hour, minute)` tuple.
1047///
1048/// The timezone-less format `"YYYY-MM-DD HH-MM-SS"` is assumed to be US Eastern
1049/// Standard Time (UTC−5). The RFC3339 format `"YYYY-MM-DDTHH:MM:SS±HH:MM"` uses
1050/// the provided UTC offset. Returns `None` if the time component is absent or
1051/// unparseable; callers should fall back to [`parse_header_date`] in that case.
1052fn parse_header_datetime_utc(value: &str) -> Option<(i32, u32, u32, u32, u32)> {
1053    let s = value.trim();
1054    if let Some(t_pos) = s.find('T') {
1055        // RFC3339: "2026-04-17T15:01:25-07:00"
1056        let (year, month, day) = parse_header_date(&s[..t_pos])?;
1057        let rest = s.get(t_pos + 1..)?;
1058        let hour: u32 = rest.get(..2)?.parse().ok()?;
1059        let minute: u32 = rest.get(3..5)?.parse().ok()?;
1060        // Offset starts after "HH:MM:SS" (8 chars)
1061        let offset = rest.get(8..)?;
1062        let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
1063        let off_h: i32 = offset.get(1..3)?.parse().ok()?;
1064        let utc_mins = hour as i32 * 60 + minute as i32 - sign * off_h * 60;
1065        return normalize_utc_datetime(year, month, day, utc_mins);
1066    }
1067    // Plain format: "2026-04-28 14-30-00", assume US Eastern Standard Time (UTC-5).
1068    let (date_part, time_part) = s.split_once(' ')?;
1069    let (year, month, day) = parse_header_date(date_part)?;
1070    let mut tp = time_part.split('-');
1071    let hour: u32 = tp.next()?.parse().ok()?;
1072    let minute: u32 = tp.next()?.parse().ok()?;
1073    normalize_utc_datetime(year, month, day, hour as i32 * 60 + minute as i32 + 5 * 60)
1074}
1075
1076/// Converts `(year, month, day)` + total UTC minutes into a `(year, month, day,
1077/// hour, minute)` tuple, carrying over into the next day as needed. Month/year
1078/// overflow is not handled — no season boundaries fall on the last day of a month.
1079fn normalize_utc_datetime(
1080    year: i32,
1081    month: u32,
1082    day: u32,
1083    utc_mins: i32,
1084) -> Option<(i32, u32, u32, u32, u32)> {
1085    let extra_days = utc_mins.div_euclid(24 * 60);
1086    let mins = utc_mins.rem_euclid(24 * 60);
1087    Some((
1088        year,
1089        month,
1090        (day as i32 + extra_days) as u32,
1091        (mins / 60) as u32,
1092        (mins % 60) as u32,
1093    ))
1094}
1095
1096/// Parses the leading calendar date (`YYYY-MM-DD`) from a replay `Date` header.
1097///
1098/// Replay dates appear as `"2026-04-28 14-30-00"` or RFC3339
1099/// `"2026-04-17T15:01:25-07:00"`; both begin with the calendar date.
1100fn parse_header_date(value: &str) -> Option<(i32, u32, u32)> {
1101    let date = value.trim().split(['T', ' ']).next()?;
1102    let mut parts = date.split('-');
1103    let year: i32 = parts.next()?.parse().ok()?;
1104    let month: u32 = parts.next()?.parse().ok()?;
1105    let day: u32 = parts.next()?.parse().ok()?;
1106    if (1..=12).contains(&month) && (1..=31).contains(&day) {
1107        Some((year, month, day))
1108    } else {
1109        None
1110    }
1111}
1112
1113/// [`ReplayMeta`] struct represents metadata about the replay being processed.
1114///
1115/// This includes information about the players in the match and all replay headers.
1116#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
1117#[ts(export)]
1118pub struct ReplayMeta {
1119    /// A vector of [`PlayerInfo`] instances representing the players on team zero.
1120    pub team_zero: Vec<PlayerInfo>,
1121    /// A vector of [`PlayerInfo`] instances representing the players on team one.
1122    pub team_one: Vec<PlayerInfo>,
1123    /// Normalized and raw game-type signals inferred from headers and network data.
1124    pub game_type: ReplayGameTypeDetails,
1125    /// Competitive season (era + number) resolved from the replay date, when known.
1126    pub season: Option<ReplaySeason>,
1127    /// A vector of tuples containing the names and properties of all the headers in the replay.
1128    #[ts(as = "Vec<(String, crate::interop::ts_bindings::HeaderPropTs)>")]
1129    pub all_headers: Vec<(String, HeaderProp)>,
1130}
1131
1132impl ReplayMeta {
1133    /// Returns the total number of players involved in the game.
1134    pub fn player_count(&self) -> usize {
1135        self.team_one.len() + self.team_zero.len()
1136    }
1137
1138    /// Returns an iterator over the [`PlayerInfo`] instances representing the players,
1139    /// in the order they are listed in the replay file.
1140    pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
1141        self.team_zero.iter().chain(self.team_one.iter())
1142    }
1143}
1144
1145/// The Rocket League camera preset a player used during the match, replicated
1146/// through `TAGame.CameraSettingsActor_TA:ProfileSettings`.
1147///
1148/// Values use the in-game units shown in Rocket League's camera settings menu
1149/// (`fov` is the horizontal field of view in degrees, distances/heights are in
1150/// unreal units, `angle` in degrees, and `stiffness`/`swivel_speed`/
1151/// `transition_speed` are the menu's dimensionless multipliers).
1152#[derive(Debug, Clone, Copy, PartialEq, Serialize, ts_rs::TS)]
1153#[ts(export)]
1154pub struct PlayerCameraSettings {
1155    /// Horizontal field of view, in degrees.
1156    pub fov: f32,
1157    /// Camera height above the car, in unreal units.
1158    pub height: f32,
1159    /// Camera pitch angle, in degrees (negative looks down).
1160    pub angle: f32,
1161    /// Camera distance behind the car, in unreal units.
1162    pub distance: f32,
1163    /// Camera stiffness in `[0, 1]`; higher tracks the car more rigidly.
1164    pub stiffness: f32,
1165    /// Swivel speed multiplier.
1166    pub swivel_speed: f32,
1167    /// Transition speed multiplier; absent in replays older than its addition.
1168    #[serde(default, skip_serializing_if = "Option::is_none")]
1169    #[ts(optional)]
1170    pub transition_speed: Option<f32>,
1171}
1172
1173impl From<&boxcars::CamSettings> for PlayerCameraSettings {
1174    fn from(settings: &boxcars::CamSettings) -> Self {
1175        Self {
1176            fov: settings.fov,
1177            height: settings.height,
1178            angle: settings.angle,
1179            distance: settings.distance,
1180            stiffness: settings.stiffness,
1181            swivel_speed: settings.swivel,
1182            transition_speed: settings.transition,
1183        }
1184    }
1185}
1186
1187/// [`PlayerInfo`] struct provides detailed information about a specific player in the replay.
1188///
1189/// This includes player's unique remote ID, player stats if available, and their name.
1190#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
1191#[ts(export)]
1192pub struct PlayerInfo {
1193    /// The unique remote ID of the player. This could be their online ID or local ID.
1194    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
1195    pub remote_id: RemoteId,
1196    /// An optional HashMap containing player-specific stats.
1197    /// The keys of this HashMap are the names of the stats,
1198    /// and the values are the corresponding `HeaderProp` instances.
1199    #[ts(
1200        as = "Option<std::collections::HashMap<String, crate::interop::ts_bindings::HeaderPropTs>>"
1201    )]
1202    pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
1203    /// The name of the player as represented in the replay.
1204    pub name: String,
1205    /// The replicated car body product id from the player's loadout, when present.
1206    #[serde(default, skip_serializing_if = "Option::is_none")]
1207    pub car_body_id: Option<u32>,
1208    /// The car body name from replay header player stats, when present.
1209    #[serde(default, skip_serializing_if = "Option::is_none")]
1210    pub car_body_name: Option<String>,
1211    /// The resolved standardized hitbox family for the player's car body, when known.
1212    #[serde(default, skip_serializing_if = "Option::is_none")]
1213    pub car_hitbox_family: Option<String>,
1214    /// The player's replicated Rocket League camera preset, when present.
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub camera_settings: Option<PlayerCameraSettings>,
1217}
1218
1219#[cfg(test)]
1220#[path = "replay_model_tests.rs"]
1221mod tests;