Skip to main content

subtr_actor/stats/calculators/
wall_aerial.rs

1use super::*;
2
3/// Minimum time a player must ride the wall before leaving it for the takeoff to
4/// count as a wall aerial. A wall aerial is launched *off the wall*, so the
5/// player must actually be on the wall surface (`wall_aerial_surface_contact`)
6/// for at least this long. Carrying the ball is *not* required — driving up the
7/// wall and then hitting the ball in the air is still a wall aerial.
8const WALL_AERIAL_MIN_WALL_CONTACT_DURATION: f32 = 0.30;
9const WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS: f32 = 1.25;
10/// The takeoff is the frame the car genuinely leaves the wall surface, so the
11/// whole flight to the touch counts against this window.
12const WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS: f32 = 2.75;
13const WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS: f32 = 3.0;
14pub(crate) const WALL_AERIAL_MIN_TOUCH_PLAYER_Z: f32 = AIR_DRIBBLE_MIN_PLAYER_Z;
15const WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z: f32 = 300.0;
16pub(crate) const WALL_AERIAL_MIN_TOUCH_BALL_Z: f32 = 400.0;
17const WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE: f32 = 80.0;
18pub(crate) const WALL_AERIAL_HIGH_CONFIDENCE: f32 = 0.78;
19
20/// Field wall geometry: flat side walls at `|x| = 4096` and end walls at
21/// `|y| = 5120`, joined by quarter-circle corner arcs of radius 1152.
22const SIDE_WALL_SURFACE_ABS_X: f32 = 4096.0;
23const BACK_WALL_SURFACE_ABS_Y: f32 = 5120.0;
24const WALL_CORNER_ARC_RADIUS: f32 = 1152.0;
25/// Field coordinates where the rounded corner arcs begin: the flat walls
26/// curve into the corner arc beyond these. Subtracting them from an on-wall
27/// position (clamping the residual at zero) leaves the outward wall normal:
28/// axis-aligned on a flat wall, radial on a corner arc.
29const WALL_CORNER_ARC_START_ABS_X: f32 = SIDE_WALL_SURFACE_ABS_X - WALL_CORNER_ARC_RADIUS;
30const WALL_CORNER_ARC_START_ABS_Y: f32 = BACK_WALL_SURFACE_ABS_Y - WALL_CORNER_ARC_RADIUS;
31/// Maximum horizontal distance from the wall surface for a frame to count as
32/// riding it. A car on the wall keeps its pivot ~17uu off the surface; the
33/// margin covers sampling jitter and bumpy rides without admitting airborne
34/// cars. (An earlier `|x| >= 3600` band reached ~500uu into the field, so an
35/// aerial jumped from the ground near the wall read as a wall ride.)
36const WALL_SURFACE_CONTACT_MAX_DISTANCE: f32 = 60.0;
37/// Minimum alignment between the car roof (up-vector) and the *inward* wall
38/// normal for a frame to count as riding the wall. Wheels on a wall point the
39/// roof at the field; 0.5 tolerates up to ~60° of lean so transitions onto the
40/// curved wall base still count. The normal comes from the same corner-arc
41/// residual as the wall classification, so side, end, and corner surfaces each
42/// use their own normal.
43const WALL_SURFACE_CONTACT_MIN_UP_ALIGNMENT: f32 = 0.5;
44/// Ratio of the smaller to the larger attack-relative axis above which a wall
45/// takeoff is treated as a (diagonal) corner. `tan(22.5°)` splits each quadrant
46/// into three equal 45° sectors across the eight [`WallAerialWall`] directions.
47const WALL_AERIAL_CORNER_AXIS_RATIO: f32 = 0.4142136;
48
49/// Which wall a player took off from, relative to their attack direction.
50///
51/// `Front`/`Back` are the end walls (the opponent's net side vs. the player's
52/// own net side); `Left`/`Right` are the side walls; the `*Left`/`*Right`
53/// variants are the rounded corners where a side wall meets an end wall.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
55#[ts(export)]
56#[serde(rename_all = "snake_case")]
57pub enum WallAerialWall {
58    Left,
59    Right,
60    Front,
61    Back,
62    FrontLeft,
63    FrontRight,
64    BackLeft,
65    BackRight,
66}
67
68impl WallAerialWall {
69    pub fn as_label_value(self) -> &'static str {
70        match self {
71            Self::Left => "left",
72            Self::Right => "right",
73            Self::Front => "front",
74            Self::Back => "back",
75            Self::FrontLeft => "front_left",
76            Self::FrontRight => "front_right",
77            Self::BackLeft => "back_left",
78            Self::BackRight => "back_right",
79        }
80    }
81}
82
83/// Coarse wall surface used internally to keep setup/continuity tracking stable
84/// while the player slides along a wall. The attack-relative [`WallAerialWall`]
85/// is computed separately at the moment of the recorded takeoff.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum WallSurface {
88    Side,
89    Back,
90}
91
92/// Horizontal outward wall normal and distance from `position` to the nearest
93/// wall surface (the flat side/end walls plus the corner arcs joining them).
94/// The distance is negative past the wall plane (e.g. inside a goal).
95pub fn wall_outward_normal_and_distance(position: glam::Vec3) -> (glam::Vec3, f32) {
96    let arc_x = position.x.signum() * (position.x.abs() - WALL_CORNER_ARC_START_ABS_X).max(0.0);
97    let arc_y = position.y.signum() * (position.y.abs() - WALL_CORNER_ARC_START_ABS_Y).max(0.0);
98    if arc_x != 0.0 && arc_y != 0.0 {
99        let radial = glam::Vec3::new(arc_x, arc_y, 0.0);
100        let radial_length = radial.length();
101        (
102            radial / radial_length,
103            WALL_CORNER_ARC_RADIUS - radial_length,
104        )
105    } else {
106        let side_distance = SIDE_WALL_SURFACE_ABS_X - position.x.abs();
107        let back_distance = BACK_WALL_SURFACE_ABS_Y - position.y.abs();
108        if side_distance <= back_distance {
109            (
110                glam::Vec3::new(position.x.signum(), 0.0, 0.0),
111                side_distance,
112            )
113        } else {
114            (
115                glam::Vec3::new(0.0, position.y.signum(), 0.0),
116                back_distance,
117            )
118        }
119    }
120}
121
122/// Whether the car is riding a wall this frame: close to the actual wall
123/// surface (within [`WALL_SURFACE_CONTACT_MAX_DISTANCE`], excluding the goal
124/// mouths) with its roof leaning into the field along the inward wall normal.
125/// Position alone is not enough — a car aerialing *near* the wall must not
126/// count as being *on* it.
127pub fn wall_aerial_surface_contact(rigid_body: &boxcars::RigidBody) -> Option<WallSurface> {
128    let position = vec_to_glam(&rigid_body.location);
129    if position.z < WALL_CONTACT_MIN_PLAYER_Z {
130        return None;
131    }
132    let (outward, distance) = wall_outward_normal_and_distance(position);
133    if distance > WALL_SURFACE_CONTACT_MAX_DISTANCE {
134        return None;
135    }
136    let is_back_surface = outward.y.abs() > outward.x.abs();
137    if is_back_surface && position.x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X {
138        return None;
139    }
140    let up = quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z;
141    if up.dot(-outward) < WALL_SURFACE_CONTACT_MIN_UP_ALIGNMENT {
142        return None;
143    }
144    Some(if is_back_surface {
145        WallSurface::Back
146    } else {
147        WallSurface::Side
148    })
149}
150
151/// Classify which wall (relative to the player's attack direction) an on-wall
152/// position is on. The residual of each axis beyond the corner-arc start is the
153/// outward wall normal — zero along an axis whose flat wall is out of reach, and
154/// the radial direction of the arc in a corner — so field position alone
155/// determines the wall, independent of car orientation.
156pub(crate) fn wall_aerial_wall_classification(
157    is_team_0: bool,
158    position: glam::Vec3,
159) -> WallAerialWall {
160    let x = position.x.signum() * (position.x.abs() - WALL_CORNER_ARC_START_ABS_X).max(0.0);
161    let y = position.y.signum() * (position.y.abs() - WALL_CORNER_ARC_START_ABS_Y).max(0.0);
162    wall_aerial_wall_from_axes(is_team_0, x, y)
163}
164
165/// Map a horizontal direction toward the wall (any positive scale) into an
166/// attack-relative [`WallAerialWall`]. `x`/`y` are field-axis components; they
167/// are normalized for team so `right` is the player's right and `front` points
168/// at the opponent's end wall. For team 0, Rocket League's positive X field
169/// axis is the player's left.
170fn wall_aerial_wall_from_axes(is_team_0: bool, x: f32, y: f32) -> WallAerialWall {
171    let right = if is_team_0 { -x } else { x };
172    let front = if is_team_0 { y } else { -y };
173    let abs_right = right.abs();
174    let abs_front = front.abs();
175    let dominant = abs_right.max(abs_front);
176    if dominant <= f32::EPSILON {
177        return WallAerialWall::Back;
178    }
179    let toward_right = right >= 0.0;
180    let toward_front = front >= 0.0;
181    if abs_right.min(abs_front) / dominant >= WALL_AERIAL_CORNER_AXIS_RATIO {
182        match (toward_front, toward_right) {
183            (true, true) => WallAerialWall::FrontRight,
184            (true, false) => WallAerialWall::FrontLeft,
185            (false, true) => WallAerialWall::BackRight,
186            (false, false) => WallAerialWall::BackLeft,
187        }
188    } else if abs_right >= abs_front {
189        if toward_right {
190            WallAerialWall::Right
191        } else {
192            WallAerialWall::Left
193        }
194    } else if toward_front {
195        WallAerialWall::Front
196    } else {
197        WallAerialWall::Back
198    }
199}
200
201pub(crate) fn wall_aerial_normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
202    if max_value <= min_value {
203        return 0.0;
204    }
205    ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
206}
207
208pub(crate) fn wall_aerial_goal_alignment(
209    is_team_0: bool,
210    ball_position: glam::Vec3,
211    ball_velocity: glam::Vec3,
212) -> f32 {
213    const GOAL_CENTER_Y: f32 = 5120.0;
214
215    let target_y = if is_team_0 {
216        GOAL_CENTER_Y
217    } else {
218        -GOAL_CENTER_Y
219    };
220    let goal_direction =
221        (glam::Vec3::new(0.0, target_y, ball_position.z) - ball_position).normalize_or_zero();
222    goal_direction.dot(ball_velocity.normalize_or_zero())
223}
224
225/// An aerial launched off a side or back wall: the player rides the wall, leaves
226/// it while airborne, and hits the ball in the air. Carrying the ball up the wall
227/// is not required.
228#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
229#[ts(export)]
230pub struct WallAerialEvent {
231    pub time: f32,
232    pub frame: usize,
233    pub sample_time: f32,
234    pub sample_frame: usize,
235    #[ts(as = "crate::interop::ts_bindings::RemoteIdTs")]
236    pub player: PlayerId,
237    pub is_team_0: bool,
238    pub wall: WallAerialWall,
239    pub wall_contact_time: f32,
240    pub wall_contact_frame: usize,
241    pub takeoff_time: f32,
242    pub takeoff_frame: usize,
243    pub time_since_takeoff: f32,
244    pub wall_contact_position: [f32; 3],
245    pub takeoff_position: [f32; 3],
246    pub player_position: [f32; 3],
247    pub ball_position: [f32; 3],
248    pub setup_start_time: f32,
249    pub setup_start_frame: usize,
250    pub setup_duration: f32,
251    pub ball_speed: f32,
252    pub ball_speed_change: f32,
253    pub goal_alignment: f32,
254    pub confidence: f32,
255}
256
257/// A continuous span of frames during which a player is on the wall surface
258/// (`wall_aerial_surface_contact`). This is the wall-aerial "setup": the player
259/// riding the wall before launching off it. Ball control is intentionally not
260/// tracked here — a wall aerial does not require carrying the ball.
261#[derive(Debug, Clone, PartialEq)]
262struct WallContactSpan {
263    /// Coarse surface used to detect that the player stayed on the *same* wall.
264    surface: WallSurface,
265    /// Attack-relative wall classification at the most recent on-wall frame; this
266    /// is the label the recorded takeoff carries.
267    wall_direction: WallAerialWall,
268    start_time: f32,
269    start_frame: usize,
270    last_time: f32,
271    last_frame: usize,
272    last_position: glam::Vec3,
273}
274
275#[derive(Debug, Clone, PartialEq)]
276struct ArmedWallAerial {
277    player: PlayerId,
278    wall_direction: WallAerialWall,
279    wall_contact_time: f32,
280    wall_contact_frame: usize,
281    wall_contact_position: glam::Vec3,
282    takeoff_time: f32,
283    takeoff_frame: usize,
284    takeoff_position: glam::Vec3,
285    setup_start_time: f32,
286    setup_start_frame: usize,
287    setup_duration: f32,
288    recorded: bool,
289}
290
291/// Detects wall aerials during live play.
292#[derive(Debug, Clone, Default)]
293pub struct WallAerialCalculator {
294    events: EventStream<WallAerialEvent>,
295    wall_contacts: HashMap<PlayerId, WallContactSpan>,
296    armed_aerials: HashMap<PlayerId, ArmedWallAerial>,
297    recent_event_times: HashMap<PlayerId, f32>,
298    previous_ball_velocity: Option<glam::Vec3>,
299}
300
301impl WallAerialCalculator {
302    pub fn new() -> Self {
303        Self::default()
304    }
305
306    pub fn events(&self) -> &[WallAerialEvent] {
307        self.events.all()
308    }
309
310    pub fn new_events(&self) -> &[WallAerialEvent] {
311        self.events.new_events()
312    }
313
314    /// Tracks each player's on-wall presence and arms a takeoff when a player
315    /// who rode the wall long enough leaves it while airborne.
316    ///
317    /// A wall aerial is "ride the wall, leave it, hit the ball in the air." We
318    /// detect the first two phases here, keyed purely on the player being on the
319    /// wall surface (`wall_aerial_surface_contact`) — no ball control required.
320    /// The attack-relative wall label is read from the field position at the
321    /// last on-wall frame (`wall_aerial_wall_classification`).
322    fn update_wall_contacts_and_takeoffs(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
323        for player in &players.players {
324            let Some(position) = player.position() else {
325                continue;
326            };
327
328            // On the wall: start or extend the contact span, and defer takeoff.
329            if let Some(surface) = player
330                .rigid_body
331                .as_ref()
332                .and_then(wall_aerial_surface_contact)
333            {
334                let wall_direction = wall_aerial_wall_classification(player.is_team_0, position);
335                match self.wall_contacts.get_mut(&player.player_id) {
336                    Some(span) if span.surface == surface => {
337                        span.last_time = frame.time;
338                        span.last_frame = frame.frame_number;
339                        span.last_position = position;
340                        span.wall_direction = wall_direction;
341                    }
342                    _ => {
343                        self.wall_contacts.insert(
344                            player.player_id.clone(),
345                            WallContactSpan {
346                                surface,
347                                wall_direction,
348                                start_time: frame.time,
349                                start_frame: frame.frame_number,
350                                last_time: frame.time,
351                                last_frame: frame.frame_number,
352                                last_position: position,
353                            },
354                        );
355                    }
356                }
357                continue;
358            }
359
360            // Off the wall and back near the ground: the player landed rather than
361            // launched, so drop any pending contact/takeoff.
362            if position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z {
363                self.wall_contacts.remove(&player.player_id);
364                self.armed_aerials.remove(&player.player_id);
365                continue;
366            }
367
368            if self.armed_aerials.contains_key(&player.player_id) {
369                continue;
370            }
371
372            // Off the wall and airborne: a takeoff. Arm it if the player rode the
373            // wall long enough and left it recently.
374            let Some(span) = self.wall_contacts.remove(&player.player_id) else {
375                continue;
376            };
377            if frame.time - span.last_time > WALL_AERIAL_MAX_WALL_CONTACT_TO_TAKEOFF_SECONDS {
378                continue;
379            }
380            let setup_duration = span.last_time - span.start_time;
381            if setup_duration < WALL_AERIAL_MIN_WALL_CONTACT_DURATION {
382                continue;
383            }
384            if self
385                .recent_event_times
386                .get(&player.player_id)
387                .is_some_and(|time| frame.time - time < WALL_AERIAL_MIN_SECONDS_BETWEEN_ATTEMPTS)
388            {
389                continue;
390            }
391            self.armed_aerials.insert(
392                player.player_id.clone(),
393                ArmedWallAerial {
394                    player: player.player_id.clone(),
395                    wall_direction: span.wall_direction,
396                    wall_contact_time: span.last_time,
397                    wall_contact_frame: span.last_frame,
398                    wall_contact_position: span.last_position,
399                    takeoff_time: frame.time,
400                    takeoff_frame: frame.frame_number,
401                    takeoff_position: position,
402                    setup_start_time: span.start_time,
403                    setup_start_frame: span.start_frame,
404                    setup_duration,
405                    recorded: false,
406                },
407            );
408        }
409    }
410
411    fn prune_armed_aerials(&mut self, current_time: f32) {
412        self.armed_aerials.retain(|_, armed| {
413            current_time - armed.takeoff_time <= WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS
414        });
415    }
416
417    fn ball_speed_change(
418        frame: &FrameInfo,
419        ball: &BallFrameState,
420        previous_ball_velocity: Option<glam::Vec3>,
421    ) -> f32 {
422        const BALL_GRAVITY_Z: f32 = -650.0;
423
424        let Some(ball) = ball.sample() else {
425            return 0.0;
426        };
427        let Some(previous_ball_velocity) = previous_ball_velocity else {
428            return 0.0;
429        };
430
431        let expected_linear_delta = glam::Vec3::new(0.0, 0.0, BALL_GRAVITY_Z * frame.dt.max(0.0));
432        let residual_linear_impulse =
433            ball.velocity() - previous_ball_velocity - expected_linear_delta;
434        residual_linear_impulse.length()
435    }
436
437    fn player_position(players: &PlayerFrameState, player_id: &PlayerId) -> Option<glam::Vec3> {
438        players
439            .players
440            .iter()
441            .find(|player| &player.player_id == player_id)
442            .and_then(PlayerSample::position)
443    }
444
445    fn controlled_play_event(
446        &self,
447        ball: &BallFrameState,
448        players: &PlayerFrameState,
449        touch: &TouchEvent,
450        ball_speed_change: f32,
451    ) -> Option<WallAerialEvent> {
452        let player_id = touch.player.as_ref()?;
453        let armed = self.armed_aerials.get(player_id)?;
454        if armed.recorded {
455            return None;
456        }
457        let player_position = Self::player_position(players, player_id)?;
458        if player_is_on_wall(player_position) || player_position.z < WALL_AERIAL_MIN_TOUCH_PLAYER_Z
459        {
460            return None;
461        }
462        let ball = ball.sample()?;
463        let ball_position = ball.position();
464        if ball_position.z < WALL_AERIAL_MIN_TOUCH_BALL_Z {
465            return None;
466        }
467        if player_position.z < WALL_AERIAL_MIN_CONTINUATION_PLAYER_Z {
468            return None;
469        }
470        let time_since_takeoff = touch.time - armed.takeoff_time;
471        if !(0.0..=WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS).contains(&time_since_takeoff) {
472            return None;
473        }
474        let confidence = 0.30
475            + 0.20
476                * wall_aerial_normalize_score(
477                    armed.setup_duration,
478                    WALL_AERIAL_MIN_WALL_CONTACT_DURATION,
479                    1.2,
480                )
481            + 0.18
482                * (1.0
483                    - wall_aerial_normalize_score(
484                        time_since_takeoff,
485                        0.15,
486                        WALL_AERIAL_MAX_TAKEOFF_TO_TOUCH_SECONDS,
487                    ))
488            + 0.16
489                * wall_aerial_normalize_score(
490                    player_position.z,
491                    WALL_AERIAL_MIN_TOUCH_PLAYER_Z,
492                    850.0,
493                )
494            + 0.16
495                * wall_aerial_normalize_score(
496                    ball_speed_change,
497                    WALL_AERIAL_REFERENCE_BALL_SPEED_CHANGE,
498                    900.0,
499                );
500
501        Some(WallAerialEvent {
502            time: touch.time,
503            frame: touch.frame,
504            sample_time: touch.time,
505            sample_frame: touch.frame,
506            player: player_id.clone(),
507            is_team_0: touch.team_is_team_0,
508            wall: armed.wall_direction,
509            wall_contact_time: armed.wall_contact_time,
510            wall_contact_frame: armed.wall_contact_frame,
511            takeoff_time: armed.takeoff_time,
512            takeoff_frame: armed.takeoff_frame,
513            time_since_takeoff,
514            wall_contact_position: armed.wall_contact_position.to_array(),
515            takeoff_position: armed.takeoff_position.to_array(),
516            player_position: player_position.to_array(),
517            ball_position: ball_position.to_array(),
518            setup_start_time: armed.setup_start_time,
519            setup_start_frame: armed.setup_start_frame,
520            setup_duration: armed.setup_duration,
521            ball_speed: ball.velocity().length(),
522            ball_speed_change,
523            goal_alignment: wall_aerial_goal_alignment(
524                touch.team_is_team_0,
525                ball_position,
526                ball.velocity(),
527            ),
528            confidence: confidence.clamp(0.0, 1.0),
529        })
530    }
531
532    fn record_event(&mut self, frame: &FrameInfo, mut event: WallAerialEvent) {
533        event.sample_time = frame.time;
534        event.sample_frame = frame.frame_number;
535        self.recent_event_times
536            .insert(event.player.clone(), event.time);
537        self.events.push(event);
538    }
539
540    pub fn update(
541        &mut self,
542        frame: &FrameInfo,
543        ball: &BallFrameState,
544        players: &PlayerFrameState,
545        touch_state: &TouchState,
546        live_play_state: &LivePlayState,
547    ) -> SubtrActorResult<()> {
548        self.events.begin_update();
549        if !live_play_state.is_live_play {
550            self.wall_contacts.clear();
551            self.armed_aerials.clear();
552            self.recent_event_times.clear();
553            self.previous_ball_velocity = ball.velocity();
554            return Ok(());
555        }
556
557        self.update_wall_contacts_and_takeoffs(frame, players);
558        self.prune_armed_aerials(frame.time);
559
560        let ball_speed_change = Self::ball_speed_change(frame, ball, self.previous_ball_velocity);
561        for touch in chronological_touch_events(&touch_state.touch_events) {
562            if let Some(event) = self.controlled_play_event(ball, players, touch, ball_speed_change)
563            {
564                if let Some(armed) = self.armed_aerials.get_mut(&event.player) {
565                    armed.recorded = true;
566                }
567                self.record_event(frame, event);
568            }
569        }
570
571        self.previous_ball_velocity = ball.velocity();
572
573        Ok(())
574    }
575}
576
577#[cfg(test)]
578#[path = "wall_aerial_tests.rs"]
579mod tests;