1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum DemolishFormat {
28 Fx,
30 Extended,
32}
33
34#[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#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
81#[ts(export)]
82pub struct DemolishInfo {
83 pub time: f32,
85 pub seconds_remaining: i32,
87 pub frame: usize,
89 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
91 pub attacker: PlayerId,
92 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
94 pub victim: PlayerId,
95 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
97 pub attacker_velocity: boxcars::Vector3f,
98 #[ts(as = "crate::interop::ts_bindings::Vector3fTs")]
100 pub victim_velocity: boxcars::Vector3f,
101 #[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 #[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#[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 pub time_after_shot: f32,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub prediction_start_time: Option<f32>,
254 #[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 pub time_after_shot: f32,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub prediction_start_time: Option<f32>,
278 #[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#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
527#[ts(export)]
528pub struct PlayerCameraStateChange {
529 pub frame: usize,
530 pub ball_cam_active: Option<bool>,
532 pub behind_view_active: Option<bool>,
534 pub driving: Option<bool>,
536}
537
538#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
539#[ts(export)]
540pub struct TouchEvent {
541 #[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 pub closest_approach_distance: Option<f32>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub contact_local_ball_position: Option<[f32; 3]>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub contact_local_hitbox_point: Option<[f32; 3]>,
569 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
587#[ts(export)]
588pub enum ReplayGameType {
589 Ranked,
591 Casual,
593 Private,
595 Offline,
597 Lan,
599 Tournament,
601 #[default]
603 Unknown,
604}
605
606#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, ts_rs::TS)]
608#[ts(export)]
609pub struct ReplayGameTypeDetails {
610 pub game_type: ReplayGameType,
612 pub header_match_type: Option<String>,
614 pub playlist_id: Option<i32>,
616 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 6 => Some(ReplayGameType::Private),
703 8 => Some(ReplayGameType::Offline),
704 1..=4 => Some(ReplayGameType::Casual),
706 10 | 11 | 13 => Some(ReplayGameType::Ranked),
708 22 | 34 => Some(ReplayGameType::Tournament),
710 23 => Some(ReplayGameType::Casual),
712 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 "online" => None,
728 _ => None,
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
738#[ts(export)]
739pub enum SeasonEra {
740 Legacy,
742 FreeToPlay,
744}
745
746impl SeasonEra {
747 fn code_prefix(self) -> char {
749 match self {
750 SeasonEra::Legacy => 's',
751 SeasonEra::FreeToPlay => 'f',
752 }
753 }
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
758#[ts(export)]
759pub struct ReplaySeason {
760 pub era: SeasonEra,
762 pub number: u8,
764}
765
766impl ReplaySeason {
767 const fn new(era: SeasonEra, number: u8) -> Self {
768 Self { era, number }
769 }
770
771 pub fn code(self) -> String {
774 format!("{}{}", self.era.code_prefix(), self.number)
775 }
776
777 pub fn start(self) -> Option<SeasonStart> {
783 SEASON_BOUNDARIES
784 .iter()
785 .find(|(_, season)| *season == self)
786 .map(|(start, _)| *start)
787 }
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ts_rs::TS)]
799#[ts(export)]
800pub struct SeasonStart {
801 pub year: i32,
803 pub month: u32,
805 pub day: u32,
807 pub hour: u32,
809 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 const fn date(self) -> (i32, u32, u32) {
827 (self.year, self.month, self.day)
828 }
829
830 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
836const SEASON_BOUNDARIES: &[(SeasonStart, ReplaySeason)] = &[
865 (
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 (
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 ), (
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 ), (
988 SeasonStart::new(2025, 6, 18, 15, 0),
989 ReplaySeason::new(SeasonEra::FreeToPlay, 19),
990 ), (
992 SeasonStart::new(2025, 9, 17, 16, 0),
993 ReplaySeason::new(SeasonEra::FreeToPlay, 20),
994 ), (
996 SeasonStart::new(2025, 12, 10, 17, 0),
997 ReplaySeason::new(SeasonEra::FreeToPlay, 21),
998 ), (
1000 SeasonStart::new(2026, 3, 11, 16, 0),
1001 ReplaySeason::new(SeasonEra::FreeToPlay, 22),
1002 ), (
1004 SeasonStart::new(2026, 6, 10, 16, 0),
1005 ReplaySeason::new(SeasonEra::FreeToPlay, 23),
1006 ), ];
1008
1009pub 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
1028fn 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
1037fn 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
1046fn 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 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 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 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
1076fn 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
1096fn 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#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
1117#[ts(export)]
1118pub struct ReplayMeta {
1119 pub team_zero: Vec<PlayerInfo>,
1121 pub team_one: Vec<PlayerInfo>,
1123 pub game_type: ReplayGameTypeDetails,
1125 pub season: Option<ReplaySeason>,
1127 #[ts(as = "Vec<(String, crate::interop::ts_bindings::HeaderPropTs)>")]
1129 pub all_headers: Vec<(String, HeaderProp)>,
1130}
1131
1132impl ReplayMeta {
1133 pub fn player_count(&self) -> usize {
1135 self.team_one.len() + self.team_zero.len()
1136 }
1137
1138 pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
1141 self.team_zero.iter().chain(self.team_one.iter())
1142 }
1143}
1144
1145#[derive(Debug, Clone, Copy, PartialEq, Serialize, ts_rs::TS)]
1153#[ts(export)]
1154pub struct PlayerCameraSettings {
1155 pub fov: f32,
1157 pub height: f32,
1159 pub angle: f32,
1161 pub distance: f32,
1163 pub stiffness: f32,
1165 pub swivel_speed: f32,
1167 #[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#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
1191#[ts(export)]
1192pub struct PlayerInfo {
1193 #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
1195 pub remote_id: RemoteId,
1196 #[ts(
1200 as = "Option<std::collections::HashMap<String, crate::interop::ts_bindings::HeaderPropTs>>"
1201 )]
1202 pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
1203 pub name: String,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub car_body_id: Option<u32>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub car_body_name: Option<String>,
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1213 pub car_hitbox_family: Option<String>,
1214 #[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;