Skip to main content

subtr_actor/stats/calculators/
flip_impulse.rs

1use super::*;
2
3const FLIP_IMPULSE_EVALUATION_SECONDS: f32 = 0.18;
4/// The orientation trajectory (the cancel and inversion signals) plays out over
5/// a longer horizon than the velocity impulse, so the candidate is held — and
6/// the dodge event resolved — at this window while the impulse itself is still
7/// measured over the short evaluation window above. `resolved_time`/`frame`
8/// continue to mark the impulse window's end, not this one.
9const FLIP_ROTATION_WINDOW_SECONDS: f32 = 0.45;
10const FLIP_IMPULSE_MAX_CANDIDATE_SECONDS: f32 = 0.6;
11const FLIP_IMPULSE_MIN_DELTA: f32 = 10.0;
12const FLIP_IMPULSE_STRONG_DELTA: f32 = 280.0;
13const BOOST_ACCELERATION_UU_PER_SECOND_SQUARED: f32 = 991.6667;
14
15/// An estimated dodge impulse derived from a measurable velocity change.
16#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
17#[ts(export)]
18pub struct DodgeImpulse {
19    pub start_position: [f32; 3],
20    pub end_position: [f32; 3],
21    pub start_speed: f32,
22    pub end_speed: f32,
23    pub raw_velocity_delta: [f32; 3],
24    pub estimated_impulse_delta: [f32; 3],
25    pub estimated_direction: [f32; 3],
26    pub estimated_horizontal_direction: [f32; 2],
27    pub estimated_impulse_magnitude: f32,
28    pub estimated_horizontal_impulse_magnitude: f32,
29    pub local_forward_component: f32,
30    pub local_right_component: f32,
31    pub local_up_component: f32,
32    pub direction_label: String,
33    pub boost_sample_count: u32,
34    pub sample_count: u32,
35    pub boost_compensation_magnitude: f32,
36    pub confidence: f32,
37}
38
39/// Orientation-trajectory features of a dodge, measured from the car's rotation
40/// over a window after onset — independent of the (noisy) velocity impulse.
41///
42/// These are the signals we actually classify a dodge on:
43/// - **forwardness** — the onset flip rotation: pitch (about the right axis)
44///   means a forward/back dodge, roll (about the forward axis) means a side
45///   dodge. Plus `min_forward_z`, how far the nose pitched down.
46/// - **cancel** — `max_forward_deviation_degrees`: how far the nose swung from
47///   where it pointed at onset. Small = the nose was held forward (a speed
48///   flip's cancel); large = the flip pitched the nose all the way over (a
49///   front/diagonal flip). Roll-invariant, so a side roll does not inflate it.
50/// - **inversion** — `max_up_deviation_degrees` / `min_up_z`: did the car roll
51///   past horizontal / go upside down (a flip's air-roll), or stay upright (a
52///   wavedash).
53#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
54#[ts(export)]
55pub struct DodgeRotation {
56    /// Body-frame angular velocity at dodge onset (the commanded flip): pitch is
57    /// rotation about the right axis, roll about the forward axis, yaw about up.
58    /// Signs are raw; the labeled corpus fixes their interpretation.
59    pub onset_pitch_rate: f32,
60    pub onset_roll_rate: f32,
61    pub onset_yaw_rate: f32,
62    /// Lowest world-z reached by the nose (forward vector): 0 = level, negative =
63    /// the nose pitched down. A forward dodge dips it; a side dodge barely does.
64    pub min_forward_z: f32,
65    /// Max angle (deg) the nose swung from its onset direction over the window.
66    pub max_forward_deviation_degrees: f32,
67    /// Max angle (deg) the up-vector swung from its onset direction over the
68    /// window (roll / inversion magnitude).
69    pub max_up_deviation_degrees: f32,
70    /// Lowest world-z reached by the up-vector: 1 = upright, 0 = on its side,
71    /// negative = (at least partly) inverted.
72    pub min_up_z: f32,
73    pub sample_count: u32,
74}
75
76/// A dodge-start event, optionally carrying an estimated dodge impulse and the
77/// orientation trajectory of the dodge.
78#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
79#[ts(export)]
80pub struct DodgeEvent {
81    pub time: f32,
82    pub frame: usize,
83    pub resolved_time: f32,
84    pub resolved_frame: usize,
85    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
86    pub player: PlayerId,
87    pub is_team_0: bool,
88    pub dodge_impulse: Option<DodgeImpulse>,
89    #[serde(default)]
90    pub dodge_rotation: Option<DodgeRotation>,
91    /// Replicated dodge torque (`TAGame.CarComponent_Dodge_TA:DodgeTorque`) read
92    /// straight off the dodge component at onset — the flip's world-frame rotation
93    /// axis (a horizontal vector; world-z ≈ 0). Unlike `dodge_rotation`'s onset
94    /// angular velocity (sampled a frame late and contaminated by any ongoing car
95    /// rotation), this is the exact commanded flip direction. `None` on inputs
96    /// that do not replicate it (e.g. the BakkesMod live path).
97    #[serde(default)]
98    pub dodge_torque: Option<[f32; 3]>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
102struct ActiveFlipImpulseCandidate {
103    is_team_0: bool,
104    start_time: f32,
105    start_frame: usize,
106    latest_time: f32,
107    latest_frame: usize,
108    start_position: glam::Vec3,
109    end_position: glam::Vec3,
110    start_velocity: glam::Vec3,
111    end_velocity: glam::Vec3,
112    local_forward: glam::Vec3,
113    local_right: glam::Vec3,
114    local_up: glam::Vec3,
115    boost_compensation: glam::Vec3,
116    sample_count: u32,
117    boost_sample_count: u32,
118    onset_local_angular_velocity: glam::Vec3,
119    min_forward_z: f32,
120    max_forward_deviation_degrees: f32,
121    max_up_deviation_degrees: f32,
122    min_up_z: f32,
123    rotation_sample_count: u32,
124    /// Replicated world-frame dodge torque captured at onset. See [`DodgeEvent`].
125    dodge_torque: Option<glam::Vec3>,
126}
127
128impl InFlightItem for ActiveFlipImpulseCandidate {
129    fn recognition(&self) -> Recognition {
130        // Speculative until it survives the evaluation window: a candidate can
131        // be pruned (stale) or discarded at a boundary before emitting.
132        Recognition::speculative(self.start_time, self.start_frame)
133    }
134
135    fn on_boundary(&mut self, _boundary: Boundary) -> Disposition {
136        // Candidates in flight at a boundary are dropped (matching the previous
137        // clear-on-stoppage / drop-at-end behavior).
138        Disposition::Discard
139    }
140}
141
142/// Detects dodge starts / flip impulses from player frame state.
143#[derive(Debug, Clone, Default, PartialEq)]
144pub struct FlipImpulseCalculator {
145    events: EventStream<DodgeEvent>,
146    active_candidates: KeyedInFlightLedger<PlayerId, ActiveFlipImpulseCandidate>,
147    previous_dodge_active: HashMap<PlayerId, bool>,
148}
149
150impl FlipImpulseCalculator {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    pub fn events(&self) -> &[DodgeEvent] {
156        self.events.all()
157    }
158
159    pub fn new_events(&self) -> &[DodgeEvent] {
160        self.events.new_events()
161    }
162
163    fn player_by_id<'a>(
164        players: &'a PlayerFrameState,
165        player_id: &PlayerId,
166    ) -> Option<&'a PlayerSample> {
167        players
168            .players
169            .iter()
170            .find(|player| &player.player_id == player_id)
171    }
172
173    fn direction_label(local_forward: f32, local_right: f32, local_up: f32) -> String {
174        let mut parts = Vec::new();
175        if local_forward.abs() >= 0.28 {
176            parts.push(if local_forward >= 0.0 {
177                "forward"
178            } else {
179                "backward"
180            });
181        }
182        if local_right.abs() >= 0.28 {
183            parts.push(if local_right >= 0.0 { "right" } else { "left" });
184        }
185        if parts.is_empty() {
186            parts.push("neutral");
187        }
188        if local_up.abs() >= 0.45 {
189            parts.push(if local_up >= 0.0 { "up" } else { "down" });
190        }
191        parts.join("_")
192    }
193
194    fn score_confidence(
195        impulse_magnitude: f32,
196        boost_compensation_magnitude: f32,
197        sample_count: u32,
198    ) -> f32 {
199        let strength_score = ((impulse_magnitude - FLIP_IMPULSE_MIN_DELTA)
200            / FLIP_IMPULSE_STRONG_DELTA)
201            .clamp(0.0, 1.0);
202        let boost_ratio = boost_compensation_magnitude
203            / (impulse_magnitude + boost_compensation_magnitude).max(1.0);
204        let boost_penalty = (1.0 - boost_ratio * 0.75).clamp(0.25, 1.0);
205        let sample_score = (sample_count as f32 / 3.0).clamp(0.35, 1.0);
206        (0.20 + 0.80 * strength_score) * boost_penalty * sample_score
207    }
208
209    fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
210        let was_dodge_active = self
211            .previous_dodge_active
212            .insert(player.player_id.clone(), player.dodge_active)
213            .unwrap_or(false);
214        if !player.dodge_active || was_dodge_active {
215            return;
216        }
217
218        let Some(rigid_body) = player.rigid_body.as_ref() else {
219            return;
220        };
221        let Some(position) = player.position() else {
222            return;
223        };
224        let Some(velocity) = player.velocity() else {
225            return;
226        };
227
228        let rotation = quat_to_glam(&rigid_body.rotation);
229        let local_forward = rotation * glam::Vec3::X;
230        let local_up = rotation * glam::Vec3::Z;
231        let onset_local_angular_velocity = rigid_body
232            .angular_velocity
233            .as_ref()
234            .map(vec_to_glam)
235            .map(|angular_velocity| rotation.inverse() * angular_velocity)
236            .unwrap_or(glam::Vec3::ZERO);
237        self.active_candidates.arm(
238            player.player_id.clone(),
239            ActiveFlipImpulseCandidate {
240                is_team_0: player.is_team_0,
241                start_time: frame.time,
242                start_frame: frame.frame_number,
243                latest_time: frame.time,
244                latest_frame: frame.frame_number,
245                start_position: position,
246                end_position: position,
247                start_velocity: velocity,
248                end_velocity: velocity,
249                local_forward,
250                local_right: rotation * glam::Vec3::Y,
251                local_up,
252                boost_compensation: glam::Vec3::ZERO,
253                sample_count: 0,
254                boost_sample_count: 0,
255                onset_local_angular_velocity,
256                min_forward_z: local_forward.z,
257                max_forward_deviation_degrees: 0.0,
258                max_up_deviation_degrees: 0.0,
259                min_up_z: local_up.z,
260                rotation_sample_count: 0,
261                dodge_torque: player.dodge_torque,
262            },
263        );
264    }
265
266    fn update_candidate(
267        candidate: &mut ActiveFlipImpulseCandidate,
268        frame: &FrameInfo,
269        player: &PlayerSample,
270    ) {
271        let elapsed = frame.time - candidate.start_time;
272        if elapsed <= 0.0 {
273            return;
274        }
275
276        // Velocity-impulse window (short): measured over the evaluation window,
277        // and `resolved_time`/`frame` mark its end.
278        if elapsed <= FLIP_IMPULSE_EVALUATION_SECONDS {
279            if let Some(position) = player.position() {
280                candidate.end_position = position;
281            }
282            if let Some(velocity) = player.velocity() {
283                candidate.end_velocity = velocity;
284                candidate.sample_count += 1;
285            }
286
287            if player.boost_active {
288                candidate.boost_sample_count += 1;
289                candidate.boost_compensation +=
290                    candidate.local_forward * BOOST_ACCELERATION_UU_PER_SECOND_SQUARED * frame.dt;
291            }
292
293            candidate.latest_time = frame.time;
294            candidate.latest_frame = frame.frame_number;
295        }
296
297        // Orientation-trajectory window (longer): the nose/up vectors relative to
298        // their onset orientation, used for the cancel and inversion signals.
299        if elapsed <= FLIP_ROTATION_WINDOW_SECONDS {
300            if let Some(rigid_body) = player.rigid_body.as_ref() {
301                let rotation = quat_to_glam(&rigid_body.rotation);
302                let forward = rotation * glam::Vec3::X;
303                let up = rotation * glam::Vec3::Z;
304                candidate.min_forward_z = candidate.min_forward_z.min(forward.z);
305                candidate.max_forward_deviation_degrees = candidate
306                    .max_forward_deviation_degrees
307                    .max(candidate.local_forward.angle_between(forward).to_degrees());
308                candidate.max_up_deviation_degrees = candidate
309                    .max_up_deviation_degrees
310                    .max(candidate.local_up.angle_between(up).to_degrees());
311                candidate.min_up_z = candidate.min_up_z.min(up.z);
312                candidate.rotation_sample_count += 1;
313            }
314        }
315    }
316
317    fn candidate_event(player_id: &PlayerId, candidate: ActiveFlipImpulseCandidate) -> DodgeEvent {
318        let raw_delta = candidate.end_velocity - candidate.start_velocity;
319        let estimated_delta = raw_delta - candidate.boost_compensation;
320        let estimated_magnitude = estimated_delta.length();
321        let dodge_impulse = (candidate.sample_count > 0
322            && estimated_magnitude >= FLIP_IMPULSE_MIN_DELTA)
323            .then(|| {
324                let direction = estimated_delta / estimated_magnitude;
325                let horizontal_delta = estimated_delta.truncate();
326                let horizontal_magnitude = horizontal_delta.length();
327                let horizontal_direction = if horizontal_magnitude > f32::EPSILON {
328                    horizontal_delta / horizontal_magnitude
329                } else {
330                    glam::Vec2::ZERO
331                };
332                let local_forward_component = direction.dot(candidate.local_forward);
333                let local_right_component = direction.dot(candidate.local_right);
334                let local_up_component = direction.dot(candidate.local_up);
335                let boost_compensation_magnitude = candidate.boost_compensation.length();
336                let confidence = Self::score_confidence(
337                    estimated_magnitude,
338                    boost_compensation_magnitude,
339                    candidate.sample_count,
340                );
341
342                DodgeImpulse {
343                    start_position: candidate.start_position.to_array(),
344                    end_position: candidate.end_position.to_array(),
345                    start_speed: candidate.start_velocity.length(),
346                    end_speed: candidate.end_velocity.length(),
347                    raw_velocity_delta: raw_delta.to_array(),
348                    estimated_impulse_delta: estimated_delta.to_array(),
349                    estimated_direction: direction.to_array(),
350                    estimated_horizontal_direction: horizontal_direction.to_array(),
351                    estimated_impulse_magnitude: estimated_magnitude,
352                    estimated_horizontal_impulse_magnitude: horizontal_magnitude,
353                    local_forward_component,
354                    local_right_component,
355                    local_up_component,
356                    direction_label: Self::direction_label(
357                        local_forward_component,
358                        local_right_component,
359                        local_up_component,
360                    ),
361                    boost_sample_count: candidate.boost_sample_count,
362                    sample_count: candidate.sample_count,
363                    boost_compensation_magnitude,
364                    confidence,
365                }
366            });
367
368        let dodge_rotation = (candidate.rotation_sample_count > 0).then_some(DodgeRotation {
369            onset_pitch_rate: candidate.onset_local_angular_velocity.y,
370            onset_roll_rate: candidate.onset_local_angular_velocity.x,
371            onset_yaw_rate: candidate.onset_local_angular_velocity.z,
372            min_forward_z: candidate.min_forward_z,
373            max_forward_deviation_degrees: candidate.max_forward_deviation_degrees,
374            max_up_deviation_degrees: candidate.max_up_deviation_degrees,
375            min_up_z: candidate.min_up_z,
376            sample_count: candidate.rotation_sample_count,
377        });
378
379        DodgeEvent {
380            time: candidate.start_time,
381            frame: candidate.start_frame,
382            resolved_time: candidate.latest_time,
383            resolved_frame: candidate.latest_frame,
384            player: player_id.clone(),
385            is_team_0: candidate.is_team_0,
386            dodge_impulse,
387            dodge_rotation,
388            dodge_torque: candidate.dodge_torque.map(|torque| torque.to_array()),
389        }
390    }
391
392    fn finalize_candidates(&mut self, frame: &FrameInfo, force_all: bool) {
393        let mut finished_candidates = Vec::new();
394
395        for (player_id, candidate) in self.active_candidates.iter() {
396            let duration = frame.time - candidate.start_time;
397            if force_all || duration >= FLIP_ROTATION_WINDOW_SECONDS {
398                finished_candidates.push((
399                    candidate.start_time,
400                    candidate.start_frame,
401                    format!("{player_id:?}"),
402                    player_id.clone(),
403                ));
404            }
405        }
406
407        finished_candidates.sort_by(|left, right| {
408            left.0
409                .total_cmp(&right.0)
410                .then_with(|| left.1.cmp(&right.1))
411                .then_with(|| left.2.cmp(&right.2))
412        });
413
414        for (_, _, _, player_id) in finished_candidates {
415            let Some(candidate) = self
416                .active_candidates
417                .finalize(&player_id, FinalizeReason::Completed)
418            else {
419                continue;
420            };
421            let event = Self::candidate_event(&player_id, candidate);
422            self.events.push(event);
423        }
424    }
425
426    pub fn update_parts(
427        &mut self,
428        frame: &FrameInfo,
429        players: &PlayerFrameState,
430        live_play_state: &LivePlayState,
431    ) -> SubtrActorResult<()> {
432        self.events.begin_update();
433
434        if !live_play_state.counts_toward_player_motion() {
435            self.active_candidates
436                .apply_boundary(Boundary::LivePlayEnded);
437            return Ok(());
438        }
439
440        for player in &players.players {
441            self.maybe_start_candidate(frame, player);
442        }
443
444        for (player_id, candidate) in self.active_candidates.iter_mut() {
445            let Some(player) = Self::player_by_id(players, player_id) else {
446                continue;
447            };
448            Self::update_candidate(candidate, frame, player);
449        }
450
451        self.finalize_candidates(frame, false);
452        self.active_candidates.retain(|_, candidate| {
453            frame.time - candidate.start_time <= FLIP_IMPULSE_MAX_CANDIDATE_SECONDS
454        });
455        Ok(())
456    }
457
458    pub fn finalize_parts(&mut self, frame: &FrameInfo) {
459        self.finalize_candidates(frame, true);
460    }
461}
462
463#[cfg(test)]
464#[path = "flip_impulse_tests.rs"]
465mod tests;