Skip to main content

subtr_actor/
util.rs

1use boxcars::{HeaderProp, RemoteId};
2use serde::Serialize;
3
4use crate::*;
5
6macro_rules! fmt_err {
7    ($( $item:expr ),* $(,)?) => {
8        Err(format!($( $item ),*))
9    };
10}
11
12pub type PlayerId = boxcars::RemoteId;
13
14/// Represents which demolition format a replay uses.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum DemolishFormat {
17    /// Old format (pre-September 2024): uses `ReplicatedDemolishGoalExplosion`
18    Fx,
19    /// New format (September 2024+): uses `ReplicatedDemolishExtended`
20    Extended,
21}
22
23/// Wrapper enum for different demolition attribute formats across Rocket League versions.
24///
25/// Rocket League changed the demolition data structure around September 2024 (v2.43+),
26/// moving from `DemolishFx` to `DemolishExtended`. This enum provides a unified interface
27/// for both formats.
28#[derive(Debug, Clone, PartialEq)]
29pub enum DemolishAttribute {
30    Fx(boxcars::DemolishFx),
31    Extended(boxcars::DemolishExtended),
32}
33
34impl DemolishAttribute {
35    pub fn attacker_actor_id(&self) -> boxcars::ActorId {
36        match self {
37            DemolishAttribute::Fx(fx) => fx.attacker,
38            DemolishAttribute::Extended(ext) => ext.attacker.actor,
39        }
40    }
41
42    pub fn victim_actor_id(&self) -> boxcars::ActorId {
43        match self {
44            DemolishAttribute::Fx(fx) => fx.victim,
45            DemolishAttribute::Extended(ext) => ext.victim.actor,
46        }
47    }
48
49    pub fn attacker_velocity(&self) -> boxcars::Vector3f {
50        match self {
51            DemolishAttribute::Fx(fx) => fx.attack_velocity,
52            DemolishAttribute::Extended(ext) => ext.attacker_velocity,
53        }
54    }
55
56    pub fn victim_velocity(&self) -> boxcars::Vector3f {
57        match self {
58            DemolishAttribute::Fx(fx) => fx.victim_velocity,
59            DemolishAttribute::Extended(ext) => ext.victim_velocity,
60        }
61    }
62}
63
64/// [`DemolishInfo`] struct represents data related to a demolition event in the game.
65///
66/// Demolition events occur when one player 'demolishes' or 'destroys' another by
67/// hitting them at a sufficiently high speed. This results in the demolished player
68/// being temporarily removed from play.
69#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct DemolishInfo {
71    /// The exact game time (in seconds) at which the demolition event occurred.
72    pub time: f32,
73    /// The remaining time in the match when the demolition event occurred.
74    pub seconds_remaining: i32,
75    /// The frame number at which the demolition occurred.
76    pub frame: usize,
77    /// The [`PlayerId`] of the player who initiated the demolition.
78    pub attacker: PlayerId,
79    /// The [`PlayerId`] of the player who was demolished.
80    pub victim: PlayerId,
81    /// The velocity of the attacker at the time of demolition.
82    pub attacker_velocity: boxcars::Vector3f,
83    /// The velocity of the victim at the time of demolition.
84    pub victim_velocity: boxcars::Vector3f,
85    /// The location of the victim at the time of demolition.
86    pub victim_location: boxcars::Vector3f,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
90pub enum BoostPadEventKind {
91    PickedUp { sequence: u8 },
92    Available,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
96pub enum BoostPadSize {
97    Big,
98    Small,
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize)]
102pub struct BoostPadEvent {
103    pub time: f32,
104    pub frame: usize,
105    pub pad_id: String,
106    pub player: Option<PlayerId>,
107    pub kind: BoostPadEventKind,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize)]
111pub struct ResolvedBoostPad {
112    pub index: usize,
113    pub pad_id: Option<String>,
114    pub size: BoostPadSize,
115    pub position: boxcars::Vector3f,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize)]
119pub struct GoalEvent {
120    pub time: f32,
121    pub frame: usize,
122    pub scoring_team_is_team_0: bool,
123    pub player: Option<PlayerId>,
124    pub team_zero_score: Option<i32>,
125    pub team_one_score: Option<i32>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
129pub enum PlayerStatEventKind {
130    Shot,
131    Save,
132    Assist,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize)]
136pub struct PlayerStatEvent {
137    pub time: f32,
138    pub frame: usize,
139    pub player: PlayerId,
140    pub is_team_0: bool,
141    pub kind: PlayerStatEventKind,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize)]
145pub struct TouchEvent {
146    pub time: f32,
147    pub frame: usize,
148    pub team_is_team_0: bool,
149    pub player: Option<PlayerId>,
150    pub closest_approach_distance: Option<f32>,
151}
152
153/// [`ReplayMeta`] struct represents metadata about the replay being processed.
154///
155/// This includes information about the players in the match and all replay headers.
156#[derive(Debug, Clone, PartialEq, Serialize)]
157pub struct ReplayMeta {
158    /// A vector of [`PlayerInfo`] instances representing the players on team zero.
159    pub team_zero: Vec<PlayerInfo>,
160    /// A vector of [`PlayerInfo`] instances representing the players on team one.
161    pub team_one: Vec<PlayerInfo>,
162    /// A vector of tuples containing the names and properties of all the headers in the replay.
163    pub all_headers: Vec<(String, HeaderProp)>,
164}
165
166impl ReplayMeta {
167    /// Returns the total number of players involved in the game.
168    pub fn player_count(&self) -> usize {
169        self.team_one.len() + self.team_zero.len()
170    }
171
172    /// Returns an iterator over the [`PlayerInfo`] instances representing the players,
173    /// in the order they are listed in the replay file.
174    pub fn player_order(&self) -> impl Iterator<Item = &PlayerInfo> {
175        self.team_zero.iter().chain(self.team_one.iter())
176    }
177}
178
179/// [`PlayerInfo`] struct provides detailed information about a specific player in the replay.
180///
181/// This includes player's unique remote ID, player stats if available, and their name.
182#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct PlayerInfo {
184    /// The unique remote ID of the player. This could be their online ID or local ID.
185    pub remote_id: RemoteId,
186    /// An optional HashMap containing player-specific stats.
187    /// The keys of this HashMap are the names of the stats,
188    /// and the values are the corresponding `HeaderProp` instances.
189    pub stats: Option<std::collections::HashMap<String, HeaderProp>>,
190    /// The name of the player as represented in the replay.
191    pub name: String,
192}
193
194pub fn find_player_stats(
195    player_id: &RemoteId,
196    name: &String,
197    all_player_stats: &Vec<Vec<(String, HeaderProp)>>,
198) -> Result<std::collections::HashMap<String, HeaderProp>, String> {
199    Ok(all_player_stats
200        .iter()
201        .find(|player_stats| matches_stats(player_id, name, player_stats))
202        .ok_or(format!(
203            "Player not found {player_id:?} {all_player_stats:?}"
204        ))?
205        .iter()
206        .cloned()
207        .collect())
208}
209
210fn matches_stats(player_id: &RemoteId, name: &String, props: &Vec<(String, HeaderProp)>) -> bool {
211    if platform_matches(player_id, props) != Ok(true) {
212        return false;
213    }
214    match player_id {
215        RemoteId::Epic(_) => name_matches(name, props),
216        RemoteId::Steam(id) => online_id_matches(*id, props),
217        RemoteId::Xbox(id) => online_id_matches(*id, props),
218        RemoteId::PlayStation(ps4id) => online_id_matches(ps4id.online_id, props),
219        RemoteId::PsyNet(psynet_id) => online_id_matches(psynet_id.online_id, props),
220        RemoteId::Switch(switch_id) => online_id_matches(switch_id.online_id, props),
221        _ => false,
222    }
223}
224
225fn name_matches(name: &String, props: &[(String, HeaderProp)]) -> bool {
226    if let Ok((_, HeaderProp::Str(stat_name))) = get_prop("Name", props) {
227        *name == stat_name
228    } else {
229        false
230    }
231}
232
233fn online_id_matches(id: u64, props: &[(String, HeaderProp)]) -> bool {
234    if let Ok((_, HeaderProp::QWord(props_id))) = get_prop("OnlineID", props) {
235        id == props_id
236    } else {
237        false
238    }
239}
240
241fn platform_matches(
242    player_id: &RemoteId,
243    props: &Vec<(String, HeaderProp)>,
244) -> Result<bool, String> {
245    if let (
246        _,
247        HeaderProp::Byte {
248            kind: _,
249            value: Some(value),
250        },
251    ) = get_prop("Platform", props)?
252    {
253        Ok(match (player_id, value.as_ref()) {
254            (RemoteId::Steam(_), "OnlinePlatform_Steam") => true,
255            (RemoteId::PlayStation(_), "OnlinePlatform_PS4") => true,
256            (RemoteId::Epic(_), "OnlinePlatform_Epic") => true,
257            (RemoteId::PsyNet(_), "OnlinePlatform_PS4") => true,
258            (RemoteId::Xbox(_), "OnlinePlatform_Dingo") => true,
259            // XXX: not sure if this is right.
260            (RemoteId::Switch(_), "OnlinePlatform_Switch") => true,
261            // TODO: There are still a few cases remaining.
262            _ => false,
263        })
264    } else {
265        fmt_err!("Unexpected platform value {:?}", props)
266    }
267}
268
269fn get_prop(prop: &str, props: &[(String, HeaderProp)]) -> Result<(String, HeaderProp), String> {
270    props
271        .iter()
272        .find(|(attr, _)| attr == prop)
273        .ok_or("Coudn't find name property".to_string())
274        .cloned()
275}
276
277pub(crate) trait VecMapEntry<K: PartialEq, V> {
278    fn get_entry(&mut self, key: K) -> Entry<'_, K, V>;
279}
280
281pub(crate) enum Entry<'a, K: PartialEq, V> {
282    Occupied(OccupiedEntry<'a, K, V>),
283    Vacant(VacantEntry<'a, K, V>),
284}
285
286impl<'a, K: PartialEq, V> Entry<'a, K, V> {
287    pub fn or_insert_with<F: FnOnce() -> V>(self, default: F) -> &'a mut V {
288        match self {
289            Entry::Occupied(occupied) => &mut occupied.entry.1,
290            Entry::Vacant(vacant) => {
291                vacant.vec.push((vacant.key, default()));
292                &mut vacant.vec.last_mut().unwrap().1
293            }
294        }
295    }
296}
297
298pub(crate) struct OccupiedEntry<'a, K: PartialEq, V> {
299    entry: &'a mut (K, V),
300}
301
302pub(crate) struct VacantEntry<'a, K: PartialEq, V> {
303    vec: &'a mut Vec<(K, V)>,
304    key: K,
305}
306
307impl<K: PartialEq + Clone, V> VecMapEntry<K, V> for Vec<(K, V)> {
308    fn get_entry(&mut self, key: K) -> Entry<'_, K, V> {
309        match self.iter_mut().position(|(k, _)| k == &key) {
310            Some(index) => Entry::Occupied(OccupiedEntry {
311                entry: &mut self[index],
312            }),
313            None => Entry::Vacant(VacantEntry { vec: self, key }),
314        }
315    }
316}
317
318pub fn vec_to_glam(v: &boxcars::Vector3f) -> glam::f32::Vec3 {
319    glam::f32::Vec3::new(v.x, v.y, v.z)
320}
321
322pub fn glam_to_vec(v: &glam::f32::Vec3) -> boxcars::Vector3f {
323    boxcars::Vector3f {
324        x: v.x,
325        y: v.y,
326        z: v.z,
327    }
328}
329
330pub fn quat_to_glam(q: &boxcars::Quaternion) -> glam::Quat {
331    glam::Quat::from_xyzw(q.x, q.y, q.z, q.w)
332}
333
334pub fn glam_to_quat(rotation: &glam::Quat) -> boxcars::Quaternion {
335    boxcars::Quaternion {
336        x: rotation.x,
337        y: rotation.y,
338        z: rotation.z,
339        w: rotation.w,
340    }
341}
342
343pub fn apply_velocities_to_rigid_body(
344    rigid_body: &boxcars::RigidBody,
345    time_delta: f32,
346) -> boxcars::RigidBody {
347    let mut interpolated = *rigid_body;
348    if time_delta == 0.0 {
349        return interpolated;
350    }
351    let linear_velocity = interpolated.linear_velocity.unwrap_or(boxcars::Vector3f {
352        x: 0.0,
353        y: 0.0,
354        z: 0.0,
355    });
356    let location = vec_to_glam(&rigid_body.location) + (time_delta * vec_to_glam(&linear_velocity));
357    interpolated.location = glam_to_vec(&location);
358    interpolated.rotation = apply_angular_velocity(rigid_body, time_delta);
359    interpolated
360}
361
362/// Ranks how plausible it is that `player_body` was the car that touched the
363/// ball near the current frame, using constant-velocity closest approach.
364///
365/// The frame's ball state can already be slightly post-contact, so we do not
366/// just compare current distance. Instead we look for the minimum ball/car
367/// separation over a short window centered slightly before the frame time.
368pub(crate) fn touch_candidate_rank(
369    ball_body: &boxcars::RigidBody,
370    player_body: &boxcars::RigidBody,
371) -> Option<(f32, f32)> {
372    const TOUCH_LOOKBACK_SECONDS: f32 = 0.12;
373    const TOUCH_LOOKAHEAD_SECONDS: f32 = 0.03;
374
375    let relative_position = vec_to_glam(&player_body.location) - vec_to_glam(&ball_body.location);
376    let current_distance = relative_position.length();
377    if !current_distance.is_finite() {
378        return None;
379    }
380
381    let relative_velocity =
382        vec_to_glam(&player_body.linear_velocity.unwrap_or(boxcars::Vector3f {
383            x: 0.0,
384            y: 0.0,
385            z: 0.0,
386        })) - vec_to_glam(&ball_body.linear_velocity.unwrap_or(boxcars::Vector3f {
387            x: 0.0,
388            y: 0.0,
389            z: 0.0,
390        }));
391    let relative_speed_squared = relative_velocity.length_squared();
392    let closest_time = if relative_speed_squared > f32::EPSILON {
393        (-relative_position.dot(relative_velocity) / relative_speed_squared)
394            .clamp(-TOUCH_LOOKBACK_SECONDS, TOUCH_LOOKAHEAD_SECONDS)
395    } else {
396        0.0
397    };
398    let closest_distance = (relative_position + relative_velocity * closest_time).length();
399    if !closest_distance.is_finite() {
400        return None;
401    }
402
403    Some((closest_distance, current_distance))
404}
405
406fn apply_angular_velocity(rigid_body: &boxcars::RigidBody, time_delta: f32) -> boxcars::Quaternion {
407    // XXX: This approach seems to give some unexpected results. There may be a
408    // unit mismatch or some other type of issue.
409    let rbav = rigid_body.angular_velocity.unwrap_or(boxcars::Vector3f {
410        x: 0.0,
411        y: 0.0,
412        z: 0.0,
413    });
414    let angular_velocity = glam::Vec3::new(rbav.x, rbav.y, rbav.z);
415    let magnitude = angular_velocity.length();
416    let angular_velocity_unit_vector = angular_velocity.normalize_or_zero();
417
418    let mut rotation = glam::Quat::from_xyzw(
419        rigid_body.rotation.x,
420        rigid_body.rotation.y,
421        rigid_body.rotation.z,
422        rigid_body.rotation.w,
423    );
424
425    if angular_velocity_unit_vector.length() != 0.0 {
426        let delta_rotation =
427            glam::Quat::from_axis_angle(angular_velocity_unit_vector, magnitude * time_delta);
428        rotation *= delta_rotation;
429    }
430
431    boxcars::Quaternion {
432        x: rotation.x,
433        y: rotation.y,
434        z: rotation.z,
435        w: rotation.w,
436    }
437}
438
439/// Interpolates between two [`boxcars::RigidBody`] states based on the provided time.
440///
441/// # Arguments
442///
443/// * `start_body` - The initial `RigidBody` state.
444/// * `start_time` - The timestamp of the initial `RigidBody` state.
445/// * `end_body` - The final `RigidBody` state.
446/// * `end_time` - The timestamp of the final `RigidBody` state.
447/// * `time` - The desired timestamp to interpolate to.
448///
449/// # Returns
450///
451/// A new [`boxcars::RigidBody`] that represents the interpolated state at the specified time.
452pub fn get_interpolated_rigid_body(
453    start_body: &boxcars::RigidBody,
454    start_time: f32,
455    end_body: &boxcars::RigidBody,
456    end_time: f32,
457    time: f32,
458) -> SubtrActorResult<boxcars::RigidBody> {
459    if !(start_time <= time && time <= end_time) {
460        return SubtrActorError::new_result(SubtrActorErrorVariant::InterpolationTimeOrderError {
461            start_time,
462            time,
463            end_time,
464        });
465    }
466
467    let duration = end_time - start_time;
468    let interpolation_amount = (time - start_time) / duration;
469    let start_position = util::vec_to_glam(&start_body.location);
470    let end_position = util::vec_to_glam(&end_body.location);
471    let interpolated_location = start_position.lerp(end_position, interpolation_amount);
472    let start_rotation = quat_to_glam(&start_body.rotation);
473    let end_rotation = quat_to_glam(&end_body.rotation);
474    let interpolated_rotation = start_rotation.slerp(end_rotation, interpolation_amount);
475
476    Ok(boxcars::RigidBody {
477        location: glam_to_vec(&interpolated_location),
478        rotation: glam_to_quat(&interpolated_rotation),
479        sleeping: start_body.sleeping,
480        linear_velocity: start_body.linear_velocity,
481        angular_velocity: start_body.angular_velocity,
482    })
483}
484
485/// Enum to define the direction of searching within a collection.
486#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
487pub enum SearchDirection {
488    Forward,
489    Backward,
490}
491
492/// Searches for an item in a slice in a specified direction and returns the
493/// first item that matches the provided predicate.
494///
495/// # Arguments
496///
497/// * `items` - The list of items to search.
498/// * `current_index` - The index to start the search from.
499/// * `direction` - The direction to search in.
500/// * `predicate` - A function that takes an item and returns an [`Option<R>`].
501///   When this function returns `Some(R)`, the item is considered a match.
502///
503/// # Returns
504///
505/// Returns a tuple of the index and the result `R` of the predicate for the first item that matches.
506pub fn find_in_direction<T, F, R>(
507    items: &[T],
508    current_index: usize,
509    direction: SearchDirection,
510    predicate: F,
511) -> Option<(usize, R)>
512where
513    F: Fn(&T) -> Option<R>,
514{
515    match direction {
516        SearchDirection::Forward => items
517            .iter()
518            .enumerate()
519            .skip(current_index + 1)
520            .find_map(|(i, item)| predicate(item).map(|res| (i, res))),
521        SearchDirection::Backward => items[..current_index]
522            .iter()
523            .enumerate()
524            .rev()
525            .find_map(|(i, item)| predicate(item).map(|res| (i, res))),
526    }
527}