Skip to main content

subtr_actor/domain/
replay_model.rs

1use boxcars::{HeaderProp, RemoteId};
2use serde::Serialize;
3
4use crate::{glam_to_vec, vec_to_glam};
5
6pub type PlayerId = boxcars::RemoteId;
7
8/// Represents which demolition format a replay uses.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DemolishFormat {
11    /// Old format (pre-September 2024): uses `ReplicatedDemolishGoalExplosion`
12    Fx,
13    /// New format (September 2024+): uses `ReplicatedDemolishExtended`
14    Extended,
15}
16
17/// Wrapper enum for different demolition attribute formats across Rocket League versions.
18///
19/// Rocket League changed the demolition data structure around September 2024 (v2.43+),
20/// moving from `DemolishFx` to `DemolishExtended`. This enum provides a unified interface
21/// for both formats.
22#[derive(Debug, Clone, PartialEq)]
23pub enum DemolishAttribute {
24    Fx(boxcars::DemolishFx),
25    Extended(boxcars::DemolishExtended),
26}
27
28impl DemolishAttribute {
29    pub fn attacker_actor_id(&self) -> boxcars::ActorId {
30        match self {
31            DemolishAttribute::Fx(fx) => fx.attacker,
32            DemolishAttribute::Extended(ext) => ext.attacker.actor,
33        }
34    }
35
36    pub fn victim_actor_id(&self) -> boxcars::ActorId {
37        match self {
38            DemolishAttribute::Fx(fx) => fx.victim,
39            DemolishAttribute::Extended(ext) => ext.victim.actor,
40        }
41    }
42
43    pub fn attacker_velocity(&self) -> boxcars::Vector3f {
44        match self {
45            DemolishAttribute::Fx(fx) => fx.attack_velocity,
46            DemolishAttribute::Extended(ext) => ext.attacker_velocity,
47        }
48    }
49
50    pub fn victim_velocity(&self) -> boxcars::Vector3f {
51        match self {
52            DemolishAttribute::Fx(fx) => fx.victim_velocity,
53            DemolishAttribute::Extended(ext) => ext.victim_velocity,
54        }
55    }
56}
57
58/// [`DemolishInfo`] struct represents data related to a demolition event in the game.
59///
60/// Demolition events occur when one player 'demolishes' or 'destroys' another by
61/// hitting them at a sufficiently high speed. This results in the demolished player
62/// being temporarily removed from play.
63#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
64#[ts(export)]
65pub struct DemolishInfo {
66    /// The exact game time (in seconds) at which the demolition event occurred.
67    pub time: f32,
68    /// The remaining time in the match when the demolition event occurred.
69    pub seconds_remaining: i32,
70    /// The frame number at which the demolition occurred.
71    pub frame: usize,
72    /// The [`PlayerId`] of the player who initiated the demolition.
73    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
74    pub attacker: PlayerId,
75    /// The [`PlayerId`] of the player who was demolished.
76    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
77    pub victim: PlayerId,
78    /// The velocity of the attacker at the time of demolition.
79    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
80    pub attacker_velocity: boxcars::Vector3f,
81    /// The velocity of the victim at the time of demolition.
82    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
83    pub victim_velocity: boxcars::Vector3f,
84    /// The location of the attacker at the time of demolition.
85    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub attacker_location: Option<boxcars::Vector3f>,
88    /// The location of the victim at the time of demolition.
89    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
90    pub victim_location: boxcars::Vector3f,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
94#[ts(export)]
95pub enum BoostPadEventKind {
96    PickedUp { sequence: u8 },
97    Available,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
101#[ts(export)]
102pub enum BoostPadSize {
103    Big,
104    Small,
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
108#[ts(export)]
109pub struct BoostPadEvent {
110    pub time: f32,
111    pub frame: usize,
112    pub pad_id: String,
113    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
114    pub player: Option<PlayerId>,
115    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub player_position: Option<boxcars::Vector3f>,
118    pub kind: BoostPadEventKind,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
122#[ts(export)]
123pub struct ResolvedBoostPad {
124    pub index: usize,
125    pub pad_id: Option<String>,
126    pub size: BoostPadSize,
127    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
128    pub position: boxcars::Vector3f,
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
132#[ts(export)]
133pub struct GoalEvent {
134    pub time: f32,
135    pub frame: usize,
136    pub scoring_team_is_team_0: bool,
137    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
138    pub player: Option<PlayerId>,
139    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub player_position: Option<boxcars::Vector3f>,
142    pub team_zero_score: Option<i32>,
143    pub team_one_score: Option<i32>,
144}
145
146/// A replay tick mark stored in the replay file.
147///
148/// Rocket League/Boxcars use tick marks for replay timeline annotations such as
149/// goal markers and other saved replay highlights. The frame is preserved from
150/// the replay body; `time` is resolved from collected frame metadata when that
151/// frame is present in the processed replay.
152#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
153#[ts(export)]
154pub struct ReplayTickMark {
155    pub description: String,
156    pub frame: i32,
157    pub time: Option<f32>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, ts_rs::TS)]
161#[ts(export)]
162pub enum PlayerStatEventKind {
163    Shot,
164    Save,
165    Assist,
166}
167
168const SHOT_TARGET_GOAL_CENTER_Y: f32 = 5120.0;
169
170#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
171#[ts(export)]
172pub struct ShotSaveMetadata {
173    pub time: f32,
174    pub frame: usize,
175    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
176    pub player: PlayerId,
177    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub player_position: Option<boxcars::Vector3f>,
180    pub is_team_0: bool,
181}
182
183#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
184#[ts(export)]
185pub struct ShotEventMetadata {
186    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
187    pub shot_touch_position: boxcars::Vector3f,
188    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
189    pub ball_position: boxcars::Vector3f,
190    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
191    pub ball_velocity: Option<boxcars::Vector3f>,
192    pub ball_speed: Option<f32>,
193    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
194    pub player_position: Option<boxcars::Vector3f>,
195    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
196    pub player_velocity: Option<boxcars::Vector3f>,
197    pub player_speed: Option<f32>,
198    pub player_distance_to_ball: Option<f32>,
199    #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
200    pub target_goal_position: boxcars::Vector3f,
201    pub distance_to_goal_center: f32,
202    pub distance_to_goal_line: f32,
203    pub ball_goal_alignment: Option<f32>,
204    pub ball_speed_toward_goal: Option<f32>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub resulting_save: Option<ShotSaveMetadata>,
207}
208
209impl ShotEventMetadata {
210    pub fn from_rigid_bodies(
211        is_team_0: bool,
212        ball_body: &boxcars::RigidBody,
213        player_body: Option<&boxcars::RigidBody>,
214    ) -> Self {
215        let ball_position = vec_to_glam(&ball_body.location);
216        let ball_velocity = ball_body.linear_velocity.as_ref().map(vec_to_glam);
217        let player_position = player_body.map(|body| vec_to_glam(&body.location));
218        let player_velocity =
219            player_body.and_then(|body| body.linear_velocity.as_ref().map(vec_to_glam));
220        let target_goal_y = if is_team_0 {
221            SHOT_TARGET_GOAL_CENTER_Y
222        } else {
223            -SHOT_TARGET_GOAL_CENTER_Y
224        };
225        let target_goal_position = glam::Vec3::new(0.0, target_goal_y, ball_position.z);
226        let goal_vector = target_goal_position - ball_position;
227        let goal_direction = goal_vector.normalize_or_zero();
228        let forward_sign = if is_team_0 { 1.0 } else { -1.0 };
229        let distance_to_goal_line = ((target_goal_y - ball_position.y) * forward_sign).max(0.0);
230        let ball_goal_alignment = ball_velocity.map(|velocity| {
231            if velocity.length_squared() <= f32::EPSILON {
232                0.0
233            } else {
234                goal_direction.dot(velocity.normalize_or_zero())
235            }
236        });
237
238        Self {
239            shot_touch_position: ball_body.location,
240            ball_position: ball_body.location,
241            ball_velocity: ball_body.linear_velocity,
242            ball_speed: ball_velocity.map(|velocity| velocity.length()),
243            player_position: player_body.map(|body| body.location),
244            player_velocity: player_body.and_then(|body| body.linear_velocity),
245            player_speed: player_velocity.map(|velocity| velocity.length()),
246            player_distance_to_ball: player_position
247                .map(|position| (position - ball_position).length()),
248            target_goal_position: glam_to_vec(&target_goal_position),
249            distance_to_goal_center: goal_vector.length(),
250            distance_to_goal_line,
251            ball_goal_alignment,
252            ball_speed_toward_goal: ball_velocity.map(|velocity| goal_direction.dot(velocity)),
253            resulting_save: None,
254        }
255    }
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
259#[ts(export)]
260pub struct PlayerStatEvent {
261    pub time: f32,
262    pub frame: usize,
263    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
264    pub player: PlayerId,
265    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub player_position: Option<boxcars::Vector3f>,
268    pub is_team_0: bool,
269    pub kind: PlayerStatEventKind,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub shot: Option<ShotEventMetadata>,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
275#[ts(export)]
276pub struct TouchEvent {
277    /// Stable identity for an attributed touch, assigned monotonically when the
278    /// stats pipeline confirms the touch. `None` for raw replay team markers,
279    /// which exist before attribution. Downstream events that reference a touch
280    /// carry this id so consumers can join exactly instead of matching on
281    /// player + frame.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    #[ts(type = "number")]
284    pub touch_id: Option<u64>,
285    pub time: f32,
286    pub frame: usize,
287    pub team_is_team_0: bool,
288    #[ts(as = "Option<crate::interop::ts_bindings::RemoteIdTs>")]
289    pub player: Option<PlayerId>,
290    #[ts(as = "Option<crate::interop::ts_bindings::Vector3fTs>")]
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub player_position: Option<boxcars::Vector3f>,
293    /// Ball-to-car hitbox contact gap in uu for attributed touches, when estimated.
294    ///
295    /// This field keeps its historical name for wire compatibility. A value of
296    /// `0.0` means the ball intersects or touches the oriented car hitbox after
297    /// subtracting the Rocket League ball collision radius.
298    pub closest_approach_distance: Option<f32>,
299    pub dodge_contact: bool,
300}
301
302impl TouchEvent {
303    pub(crate) fn timestamp_ordering(left: &Self, right: &Self) -> std::cmp::Ordering {
304        left.frame
305            .cmp(&right.frame)
306            .then_with(|| left.time.total_cmp(&right.time))
307    }
308}
309
310pub(crate) const TOUCH_RATE_LIMIT_SECONDS: f32 = 0.25;
311
312/// Normalized high-level match type inferred from replay headers and network data.
313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
314#[ts(export)]
315pub enum ReplayGameType {
316    /// Public ranked matchmaking.
317    Ranked,
318    /// Public unranked/casual matchmaking.
319    Casual,
320    /// Private match.
321    Private,
322    /// Local/offline exhibition match.
323    Offline,
324    /// LAN match.
325    Lan,
326    /// Tournament match.
327    Tournament,
328    /// The replay did not expose enough recognized metadata to classify the game type.
329    #[default]
330    Unknown,
331}
332
333/// Raw and normalized game-type metadata for a replay.
334#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
335#[ts(export)]
336pub struct ReplayGameTypeDetails {
337    /// Easy-to-use normalized classification.
338    pub game_type: ReplayGameType,
339    /// Header `MatchType`, when present. Post-EAC online replays often only say `Online`.
340    pub header_match_type: Option<String>,
341    /// Network `ProjectX.GRI_X:ReplicatedGamePlaylist`, when present.
342    pub playlist_id: Option<i32>,
343    /// Network `TAGame.GameEvent_TA:MatchTypeClass`, resolved to its actor object name.
344    pub match_type_class: Option<String>,
345}
346
347impl ReplayGameTypeDetails {
348    pub fn from_headers(headers: &[(String, HeaderProp)]) -> Self {
349        let header_match_type = headers
350            .iter()
351            .find(|(key, _)| key == "MatchType")
352            .and_then(|(_, value)| value.as_string())
353            .map(ToOwned::to_owned);
354
355        Self::from_signals(header_match_type, None, None)
356    }
357
358    pub fn from_signals(
359        header_match_type: Option<String>,
360        playlist_id: Option<i32>,
361        match_type_class: Option<String>,
362    ) -> Self {
363        let game_type = infer_replay_game_type(
364            header_match_type.as_deref(),
365            playlist_id,
366            match_type_class.as_deref(),
367        );
368        Self {
369            game_type,
370            header_match_type,
371            playlist_id,
372            match_type_class,
373        }
374    }
375
376    pub fn with_network_signals(
377        &self,
378        playlist_id: Option<i32>,
379        match_type_class: Option<String>,
380    ) -> Self {
381        Self::from_signals(
382            self.header_match_type.clone(),
383            playlist_id.or(self.playlist_id),
384            match_type_class.or_else(|| self.match_type_class.clone()),
385        )
386    }
387}
388
389fn infer_replay_game_type(
390    header_match_type: Option<&str>,
391    playlist_id: Option<i32>,
392    match_type_class: Option<&str>,
393) -> ReplayGameType {
394    if let Some(game_type) = match_type_class.and_then(replay_game_type_from_match_type_class) {
395        return game_type;
396    }
397    if let Some(game_type) = header_match_type.and_then(replay_game_type_from_header_match_type) {
398        return game_type;
399    }
400    if let Some(game_type) = playlist_id.and_then(replay_game_type_from_playlist_id) {
401        return game_type;
402    }
403    ReplayGameType::Unknown
404}
405
406fn replay_game_type_from_match_type_class(class_name: &str) -> Option<ReplayGameType> {
407    let normalized = class_name.to_ascii_lowercase();
408    if normalized.contains("publicranked") {
409        Some(ReplayGameType::Ranked)
410    } else if normalized.contains("private") {
411        Some(ReplayGameType::Private)
412    } else if normalized.contains("offline") {
413        Some(ReplayGameType::Offline)
414    } else if normalized.contains("lan") {
415        Some(ReplayGameType::Lan)
416    } else if normalized.contains("tournament") {
417        Some(ReplayGameType::Tournament)
418    } else if normalized.contains("public") {
419        Some(ReplayGameType::Casual)
420    } else {
421        None
422    }
423}
424
425fn replay_game_type_from_playlist_id(playlist_id: i32) -> Option<ReplayGameType> {
426    match playlist_id {
427        // Private and offline fixtures use these playlist ids, but LAN can also
428        // report 6, so header/class signals intentionally take precedence.
429        6 => Some(ReplayGameType::Private),
430        8 => Some(ReplayGameType::Offline),
431        // Unranked Duel, Doubles, Standard, and Chaos.
432        1..=4 => Some(ReplayGameType::Casual),
433        // Ranked Duel, Doubles, and Standard.
434        10 | 11 | 13 => Some(ReplayGameType::Ranked),
435        // Tournament-style fixtures observed across older and current replays.
436        22 | 34 => Some(ReplayGameType::Tournament),
437        // Older public playlist observed in the fixture corpus.
438        23 => Some(ReplayGameType::Casual),
439        // Ranked extra modes.
440        27..=30 => Some(ReplayGameType::Ranked),
441        _ => None,
442    }
443}
444
445fn replay_game_type_from_header_match_type(match_type: &str) -> Option<ReplayGameType> {
446    match match_type.to_ascii_lowercase().as_str() {
447        "ranked" => Some(ReplayGameType::Ranked),
448        "unranked" | "casual" => Some(ReplayGameType::Casual),
449        "private" => Some(ReplayGameType::Private),
450        "offline" => Some(ReplayGameType::Offline),
451        "lan" => Some(ReplayGameType::Lan),
452        "tournament" => Some(ReplayGameType::Tournament),
453        // Header-only `Online` is intentionally ambiguous.
454        "online" => None,
455        _ => None,
456    }
457}
458
459/// Which competitive-season numbering era a replay belongs to.
460///
461/// Rocket League restarted its season counter at 1 when it went free-to-play in
462/// September 2020, so the era is required to disambiguate (e.g. legacy Season 8
463/// vs free-to-play Season 8).
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
465#[ts(export)]
466pub enum SeasonEra {
467    /// Pre-free-to-play numbered competitive seasons (Season 1–14, 2016–2020).
468    Legacy,
469    /// Free-to-play seasons (Season 1 onward, from September 2020).
470    FreeToPlay,
471}
472
473impl SeasonEra {
474    /// Single-character code prefix used in the canonical season code.
475    fn code_prefix(self) -> char {
476        match self {
477            SeasonEra::Legacy => 's',
478            SeasonEra::FreeToPlay => 'f',
479        }
480    }
481}
482
483/// A resolved competitive season, identified by its numbering era and number.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
485#[ts(export)]
486pub struct ReplaySeason {
487    /// Which numbering era the season belongs to.
488    pub era: SeasonEra,
489    /// Season number within its era (1-based).
490    pub number: u8,
491}
492
493impl ReplaySeason {
494    const fn new(era: SeasonEra, number: u8) -> Self {
495        Self { era, number }
496    }
497
498    /// Canonical short code, e.g. `f21` (free-to-play) or `s14` (legacy). Used as
499    /// the stable string key for storage, filtering, and display.
500    pub fn code(self) -> String {
501        format!("{}{}", self.era.code_prefix(), self.number)
502    }
503
504    /// The UTC instant this season went live, from [`SEASON_BOUNDARIES`].
505    ///
506    /// Always `Some` for a season produced by resolution, since resolved seasons
507    /// come from the table; returns `None` only for a hand-constructed
508    /// [`ReplaySeason`] that has no boundary entry.
509    pub fn start(self) -> Option<SeasonStart> {
510        SEASON_BOUNDARIES
511            .iter()
512            .find(|(_, season)| *season == self)
513            .map(|(start, _)| *start)
514    }
515}
516
517/// The UTC instant a competitive season went live.
518///
519/// Stored per season so callers can display or reason about a boundary at finer
520/// than day precision. Season *resolution* still uses only the date (see
521/// [`season_for_date`]): the replay `Date` header is timezone-less local
522/// wall-clock, so its time-of-day cannot be meaningfully compared against a UTC
523/// instant. The time is therefore informational, and rough for older seasons
524/// (see [`SEASON_BOUNDARIES`]).
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
526#[ts(export)]
527pub struct SeasonStart {
528    /// UTC calendar year.
529    pub year: i32,
530    /// UTC month, 1-based.
531    pub month: u32,
532    /// UTC day of month, 1-based.
533    pub day: u32,
534    /// UTC hour, 0–23.
535    pub hour: u32,
536    /// UTC minute, 0–59.
537    pub minute: u32,
538}
539
540impl SeasonStart {
541    const fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> Self {
542        Self {
543            year,
544            month,
545            day,
546            hour,
547            minute,
548        }
549    }
550
551    /// The `(year, month, day)` UTC date portion, used for day-granular
552    /// resolution against the replay's local-wall-clock date.
553    const fn date(self) -> (i32, u32, u32) {
554        (self.year, self.month, self.day)
555    }
556
557    /// Full UTC datetime as a comparable tuple `(year, month, day, hour, minute)`.
558    const fn as_datetime_tuple(self) -> (i32, u32, u32, u32, u32) {
559        (self.year, self.month, self.day, self.hour, self.minute)
560    }
561}
562
563/// Competitive-season start dates, ascending by date.
564///
565/// Rocket League replays do not record the competitive season directly, so it is
566/// resolved from the recorded match `Date` against this table. Each entry records
567/// only the date a season *began*; a season runs until the next entry's start.
568/// Seasons are contiguous, so storing only the start (rather than start/end pairs)
569/// keeps the table impossible to leave with gaps or overlaps.
570///
571/// Each entry is the UTC instant the season went live (see [`SeasonStart`]).
572/// [`SeasonStart::new`] is `const` and the entries already order correctly, so
573/// the table needs no parsing or lazy initialization.
574///
575/// Resolution itself is day-granular (see [`season_for_date`]): the replay
576/// `Date` header is local wall-clock with no timezone, so a replay recorded
577/// within a day of a boundary is inherently ambiguous and the stored time-of-day
578/// cannot be compared against it. The time is stored per season purely as data
579/// for callers that want it (display, reporting).
580///
581/// Time precision by source:
582/// - Free-to-play S3 and S18–S23: exact go-live time from the official
583///   Rocket League "Season N Live" patch notes (which the season megathreads on
584///   r/RocketLeague cite). These announce a time like "9 AM PT / 4 PM UTC".
585/// - All other seasons: the date is the launch date, but the time is the
586///   *approximate* standard ~9 AM Pacific launch converted to UTC (16:00 in
587///   PDT, 17:00 in PST). Treat the hour for these as rough.
588///
589/// TODO(season-dates): the legacy (pre-free-to-play) start dates are still
590/// best-effort and have not been cross-checked against patch notes.
591const SEASON_BOUNDARIES: &[(SeasonStart, ReplaySeason)] = &[
592    // Pre-free-to-play numbered competitive seasons. Times approximate (~9 AM PT).
593    (
594        SeasonStart::new(2016, 2, 10, 17, 0),
595        ReplaySeason::new(SeasonEra::Legacy, 1),
596    ),
597    (
598        SeasonStart::new(2016, 6, 20, 16, 0),
599        ReplaySeason::new(SeasonEra::Legacy, 2),
600    ),
601    (
602        SeasonStart::new(2016, 9, 8, 16, 0),
603        ReplaySeason::new(SeasonEra::Legacy, 3),
604    ),
605    (
606        SeasonStart::new(2017, 3, 22, 16, 0),
607        ReplaySeason::new(SeasonEra::Legacy, 4),
608    ),
609    (
610        SeasonStart::new(2017, 9, 13, 16, 0),
611        ReplaySeason::new(SeasonEra::Legacy, 5),
612    ),
613    (
614        SeasonStart::new(2018, 3, 7, 17, 0),
615        ReplaySeason::new(SeasonEra::Legacy, 6),
616    ),
617    (
618        SeasonStart::new(2018, 9, 25, 16, 0),
619        ReplaySeason::new(SeasonEra::Legacy, 7),
620    ),
621    (
622        SeasonStart::new(2019, 3, 27, 16, 0),
623        ReplaySeason::new(SeasonEra::Legacy, 8),
624    ),
625    (
626        SeasonStart::new(2019, 8, 22, 16, 0),
627        ReplaySeason::new(SeasonEra::Legacy, 9),
628    ),
629    (
630        SeasonStart::new(2019, 12, 4, 17, 0),
631        ReplaySeason::new(SeasonEra::Legacy, 10),
632    ),
633    (
634        SeasonStart::new(2020, 4, 8, 16, 0),
635        ReplaySeason::new(SeasonEra::Legacy, 11),
636    ),
637    (
638        SeasonStart::new(2020, 7, 8, 16, 0),
639        ReplaySeason::new(SeasonEra::Legacy, 12),
640    ),
641    // Free-to-play era. Times approximate (~9 AM PT) except where noted "verified".
642    (
643        SeasonStart::new(2020, 9, 23, 16, 0),
644        ReplaySeason::new(SeasonEra::FreeToPlay, 1),
645    ),
646    (
647        SeasonStart::new(2020, 12, 9, 17, 0),
648        ReplaySeason::new(SeasonEra::FreeToPlay, 2),
649    ),
650    (
651        SeasonStart::new(2021, 4, 7, 15, 0),
652        ReplaySeason::new(SeasonEra::FreeToPlay, 3),
653    ), // verified: 8 AM PDT
654    (
655        SeasonStart::new(2021, 8, 11, 16, 0),
656        ReplaySeason::new(SeasonEra::FreeToPlay, 4),
657    ),
658    (
659        SeasonStart::new(2021, 11, 17, 17, 0),
660        ReplaySeason::new(SeasonEra::FreeToPlay, 5),
661    ),
662    (
663        SeasonStart::new(2022, 3, 9, 17, 0),
664        ReplaySeason::new(SeasonEra::FreeToPlay, 6),
665    ),
666    (
667        SeasonStart::new(2022, 6, 15, 16, 0),
668        ReplaySeason::new(SeasonEra::FreeToPlay, 7),
669    ),
670    (
671        SeasonStart::new(2022, 9, 7, 16, 0),
672        ReplaySeason::new(SeasonEra::FreeToPlay, 8),
673    ),
674    (
675        SeasonStart::new(2022, 12, 7, 17, 0),
676        ReplaySeason::new(SeasonEra::FreeToPlay, 9),
677    ),
678    (
679        SeasonStart::new(2023, 3, 8, 17, 0),
680        ReplaySeason::new(SeasonEra::FreeToPlay, 10),
681    ),
682    (
683        SeasonStart::new(2023, 6, 7, 16, 0),
684        ReplaySeason::new(SeasonEra::FreeToPlay, 11),
685    ),
686    (
687        SeasonStart::new(2023, 9, 6, 16, 0),
688        ReplaySeason::new(SeasonEra::FreeToPlay, 12),
689    ),
690    (
691        SeasonStart::new(2023, 12, 6, 17, 0),
692        ReplaySeason::new(SeasonEra::FreeToPlay, 13),
693    ),
694    (
695        SeasonStart::new(2024, 3, 6, 17, 0),
696        ReplaySeason::new(SeasonEra::FreeToPlay, 14),
697    ),
698    (
699        SeasonStart::new(2024, 6, 5, 16, 0),
700        ReplaySeason::new(SeasonEra::FreeToPlay, 15),
701    ),
702    (
703        SeasonStart::new(2024, 9, 4, 16, 0),
704        ReplaySeason::new(SeasonEra::FreeToPlay, 16),
705    ),
706    (
707        SeasonStart::new(2024, 12, 4, 17, 0),
708        ReplaySeason::new(SeasonEra::FreeToPlay, 17),
709    ),
710    (
711        SeasonStart::new(2025, 3, 14, 16, 0),
712        ReplaySeason::new(SeasonEra::FreeToPlay, 18),
713    ), // verified: 9 AM PDT
714    (
715        SeasonStart::new(2025, 6, 18, 15, 0),
716        ReplaySeason::new(SeasonEra::FreeToPlay, 19),
717    ), // verified: 8 AM PDT
718    (
719        SeasonStart::new(2025, 9, 17, 16, 0),
720        ReplaySeason::new(SeasonEra::FreeToPlay, 20),
721    ), // verified: 9 AM PDT
722    (
723        SeasonStart::new(2025, 12, 10, 17, 0),
724        ReplaySeason::new(SeasonEra::FreeToPlay, 21),
725    ), // verified: 9 AM PST
726    (
727        SeasonStart::new(2026, 3, 11, 16, 0),
728        ReplaySeason::new(SeasonEra::FreeToPlay, 22),
729    ), // verified: 9 AM PDT
730    (
731        SeasonStart::new(2026, 6, 10, 16, 0),
732        ReplaySeason::new(SeasonEra::FreeToPlay, 23),
733    ), // verified: 9 AM PDT
734];
735
736/// Resolves the competitive season from replay headers via the recorded match
737/// date. Returns `None` when no usable date is present or the replay predates the
738/// first known season.
739pub fn season_from_headers(headers: &[(String, HeaderProp)]) -> Option<ReplaySeason> {
740    headers
741        .iter()
742        .find(|(key, _)| {
743            ["Date", "ReplayDate", "RecordDate"]
744                .iter()
745                .any(|name| key.eq_ignore_ascii_case(name))
746        })
747        .and_then(|(_, value)| value.as_string())
748        .and_then(|s| {
749            parse_header_datetime_utc(s)
750                .and_then(season_for_datetime)
751                .or_else(|| parse_header_date(s).and_then(season_for_date))
752        })
753}
754
755/// Returns the most recent season whose start is on or before `dt` (UTC).
756fn season_for_datetime(dt: (i32, u32, u32, u32, u32)) -> Option<ReplaySeason> {
757    SEASON_BOUNDARIES
758        .iter()
759        .rev()
760        .find(|(start, _)| start.as_datetime_tuple() <= dt)
761        .map(|(_, season)| *season)
762}
763
764/// Returns the most recent season that began on or before `date`.
765fn season_for_date(date: (i32, u32, u32)) -> Option<ReplaySeason> {
766    SEASON_BOUNDARIES
767        .iter()
768        .rev()
769        .find(|(start, _)| start.date() <= date)
770        .map(|(_, season)| *season)
771}
772
773/// Parses the replay `Date` header as a UTC `(year, month, day, hour, minute)` tuple.
774///
775/// The timezone-less format `"YYYY-MM-DD HH-MM-SS"` is assumed to be US Eastern
776/// Standard Time (UTC−5). The RFC3339 format `"YYYY-MM-DDTHH:MM:SS±HH:MM"` uses
777/// the provided UTC offset. Returns `None` if the time component is absent or
778/// unparseable; callers should fall back to [`parse_header_date`] in that case.
779fn parse_header_datetime_utc(value: &str) -> Option<(i32, u32, u32, u32, u32)> {
780    let s = value.trim();
781    if let Some(t_pos) = s.find('T') {
782        // RFC3339: "2026-04-17T15:01:25-07:00"
783        let (year, month, day) = parse_header_date(&s[..t_pos])?;
784        let rest = s.get(t_pos + 1..)?;
785        let hour: u32 = rest.get(..2)?.parse().ok()?;
786        let minute: u32 = rest.get(3..5)?.parse().ok()?;
787        // Offset starts after "HH:MM:SS" (8 chars)
788        let offset = rest.get(8..)?;
789        let sign: i32 = if offset.starts_with('-') { -1 } else { 1 };
790        let off_h: i32 = offset.get(1..3)?.parse().ok()?;
791        let utc_mins = hour as i32 * 60 + minute as i32 - sign * off_h * 60;
792        return normalize_utc_datetime(year, month, day, utc_mins);
793    }
794    // Plain format: "2026-04-28 14-30-00", assume US Eastern Standard Time (UTC-5).
795    let (date_part, time_part) = s.split_once(' ')?;
796    let (year, month, day) = parse_header_date(date_part)?;
797    let mut tp = time_part.split('-');
798    let hour: u32 = tp.next()?.parse().ok()?;
799    let minute: u32 = tp.next()?.parse().ok()?;
800    normalize_utc_datetime(year, month, day, hour as i32 * 60 + minute as i32 + 5 * 60)
801}
802
803/// Converts `(year, month, day)` + total UTC minutes into a `(year, month, day,
804/// hour, minute)` tuple, carrying over into the next day as needed. Month/year
805/// overflow is not handled — no season boundaries fall on the last day of a month.
806fn normalize_utc_datetime(
807    year: i32,
808    month: u32,
809    day: u32,
810    utc_mins: i32,
811) -> Option<(i32, u32, u32, u32, u32)> {
812    let extra_days = utc_mins.div_euclid(24 * 60);
813    let mins = utc_mins.rem_euclid(24 * 60);
814    Some((
815        year,
816        month,
817        (day as i32 + extra_days) as u32,
818        (mins / 60) as u32,
819        (mins % 60) as u32,
820    ))
821}
822
823/// Parses the leading calendar date (`YYYY-MM-DD`) from a replay `Date` header.
824///
825/// Replay dates appear as `"2026-04-28 14-30-00"` or RFC3339
826/// `"2026-04-17T15:01:25-07:00"`; both begin with the calendar date.
827fn parse_header_date(value: &str) -> Option<(i32, u32, u32)> {
828    let date = value.trim().split(['T', ' ']).next()?;
829    let mut parts = date.split('-');
830    let year: i32 = parts.next()?.parse().ok()?;
831    let month: u32 = parts.next()?.parse().ok()?;
832    let day: u32 = parts.next()?.parse().ok()?;
833    if (1..=12).contains(&month) && (1..=31).contains(&day) {
834        Some((year, month, day))
835    } else {
836        None
837    }
838}
839
840/// [`ReplayMeta`] struct represents metadata about the replay being processed.
841///
842/// This includes information about the players in the match and all replay headers.
843#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
844#[ts(export)]
845pub struct ReplayMeta {
846    /// A vector of [`PlayerInfo`] instances representing the players on team zero.
847    pub team_zero: Vec<PlayerInfo>,
848    /// A vector of [`PlayerInfo`] instances representing the players on team one.
849    pub team_one: Vec<PlayerInfo>,
850    /// Normalized and raw game-type signals inferred from headers and network data.
851    pub game_type: ReplayGameTypeDetails,
852    /// Competitive season (era + number) resolved from the replay date, when known.
853    pub season: Option<ReplaySeason>,
854    /// A vector of tuples containing the names and properties of all the headers in the replay.
855    #[ts(as = "Vec<(String, crate::interop::ts_bindings::HeaderPropTs)>")]
856    pub all_headers: Vec<(String, HeaderProp)>,
857}
858
859impl ReplayMeta {
860    /// Returns the total number of players involved in the game.
861    pub fn player_count(&self) -> usize {
862        self.team_one.len() + self.team_zero.len()
863    }
864
865    /// Returns an iterator over the [`PlayerInfo`] instances representing the players,
866    /// in the order they are listed in the replay file.
867    pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
868        self.team_zero.iter().chain(self.team_one.iter())
869    }
870}
871
872/// [`PlayerInfo`] struct provides detailed information about a specific player in the replay.
873///
874/// This includes player's unique remote ID, player stats if available, and their name.
875#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
876#[ts(export)]
877pub struct PlayerInfo {
878    /// The unique remote ID of the player. This could be their online ID or local ID.
879    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
880    pub remote_id: RemoteId,
881    /// An optional HashMap containing player-specific stats.
882    /// The keys of this HashMap are the names of the stats,
883    /// and the values are the corresponding `HeaderProp` instances.
884    #[ts(
885        as = "Option<std::collections::HashMap<String, crate::interop::ts_bindings::HeaderPropTs>>"
886    )]
887    pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
888    /// The name of the player as represented in the replay.
889    pub name: String,
890    /// The replicated car body product id from the player's loadout, when present.
891    #[serde(default, skip_serializing_if = "Option::is_none")]
892    pub car_body_id: Option<u32>,
893    /// The car body name from replay header player stats, when present.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub car_body_name: Option<String>,
896    /// The resolved standardized hitbox family for the player's car body, when known.
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub car_hitbox_family: Option<String>,
899}
900
901#[cfg(test)]
902#[path = "replay_model_tests.rs"]
903mod tests;