From 9575567a1f3b6d936dfd3d1e9c5fe094f2a0b507 Mon Sep 17 00:00:00 2001 From: chazmm Date: Mon, 7 Sep 2026 21:35:54 +0100 Subject: [PATCH] Connect difficulty-dependent native spline acquisition and airborne grind adjustment --- crates/skate-core/src/air/trajectory/grind.rs | 17 +- .../src/air/trajectory/grind_surface.rs | 55 ++- .../skate-core/src/air/trajectory/scoring.rs | 35 +- .../skate-core/src/air/trajectory/selector.rs | 48 ++- crates/skate-core/src/physics/grind_air.rs | 338 ++++++++++++++++++ crates/skate-core/src/physics/mod.rs | 2 + .../src/physics/air_trajectory/grind.rs | 165 +++++++++ .../src/physics/air_trajectory/mod.rs | 30 +- .../src/physics/air_trajectory/settings.rs | 2 +- .../src/physics/air_trajectory/world.rs | 11 +- crates/skate-game/src/physics/frame.rs | 3 + crates/skate-game/src/physics/grind.rs | 2 +- .../src/physics/grind_air_settings.rs | 67 ++++ .../src/physics/known_air/runtime.rs | 13 +- crates/skate-game/src/physics/skater.rs | 1 + .../src/physics/skeleton_input_runtime.rs | 58 ++- docs/grind-families.md | 6 +- docs/grind-magnetism-difficulty.md | 37 +- docs/grind-native-assist.md | 66 +--- 19 files changed, 846 insertions(+), 110 deletions(-) create mode 100644 crates/skate-core/src/physics/grind_air.rs create mode 100644 crates/skate-game/src/physics/air_trajectory/grind.rs create mode 100644 crates/skate-game/src/physics/grind_air_settings.rs diff --git a/crates/skate-core/src/air/trajectory/grind.rs b/crates/skate-core/src/air/trajectory/grind.rs index 87652268..747de9bd 100644 --- a/crates/skate-core/src/air/trajectory/grind.rs +++ b/crates/skate-core/src/air/trajectory/grind.rs @@ -1,6 +1,6 @@ //! TU3 rail-trajectory candidate leaves82D60C80/82D60B98, //! 82D6A398 and82D6A168. Admission and physical adjustment are separate owners. -use super::{Prediction, Trajectory, math::*}; +use super::{math::*, Prediction, Trajectory}; use crate::{physics::grind_contact::Primitive, point_graph::PointGraph}; #[derive(Clone, Copy, Debug)] @@ -326,3 +326,18 @@ pub fn take_best_grind( } chosen.map(|i| candidates.remove(i)) } + +/// A real world query and its accepted target, retained through selector scoring. +#[derive(Clone, Copy, Debug)] +pub struct GrindTarget { + pub air_limits: [Vector; 3], + pub edge: Primitive, + pub point: Vector, + pub normal: Vector, +} +#[derive(Clone, Copy, Debug)] +pub struct GrindEvaluation { + pub target: Option, + pub score: f32, + pub distance: f32, +} diff --git a/crates/skate-core/src/air/trajectory/grind_surface.rs b/crates/skate-core/src/air/trajectory/grind_surface.rs index eb9d3538..dc8af05d 100644 --- a/crates/skate-core/src/air/trajectory/grind_surface.rs +++ b/crates/skate-core/src/air/trajectory/grind_surface.rs @@ -4,7 +4,7 @@ use super::{grind::GrindSurfaceEvidence, math::*}; use crate::math::Vector3; use crate::physics::{ board_world::BoardWorld, - ground_hang_geometry::{HangGeometryInput, hang_lines}, + ground_hang_geometry::{hang_lines, HangGeometryInput}, }; #[derive(Clone, Copy, Debug)] @@ -113,3 +113,56 @@ fn rotate(axis: Vector, value: Vector, angle: f32) -> Vector { fn xyz(v: Vector) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } + +impl GrindSurface { + ///82C21930's three angular reference directions for GrindAirAdjust. + pub fn air_limits( + &self, + start: Vector, + end: Vector, + point: Vector, + board_position: Vector, + ) -> [Vector; 3] { + let rail = normalize(sub(end, start)); + let up = normalize(cross(rail, cross(UP, rail))); + let side = cross(up, rail); + if self.evidence.kind == 0 { + let sign = if dot(side, sub(board_position, point)) > 0.0 { + 1.0 + } else { + -1.0 + }; + let oriented = scale(side, sign); + let axis = cross(oriented, up); + [ + side, + rotate(axis, oriented, f32::from_bits(0x3eb2b8c3)), + rotate(axis, oriented, f32::from_bits(0x401a25c2)), + ] + } else { + let sign = if dot(side, self.evidence.side) > 0.0 { + 1.0 + } else { + -1.0 + }; + let axis = cross(side, up); + [ + if self.evidence.kind == 1 { + side + } else { + cross(self.normal, rail) + }, + rotate( + scale(axis, -sign), + scale(side, -sign), + f32::from_bits(0x3eb2b8c3), + ), + rotate( + scale(axis, sign), + scale(side, sign), + f32::from_bits(0x3f3ba866), + ), + ] + } + } +} diff --git a/crates/skate-core/src/air/trajectory/scoring.rs b/crates/skate-core/src/air/trajectory/scoring.rs index db97cd01..889d8907 100644 --- a/crates/skate-core/src/air/trajectory/scoring.rs +++ b/crates/skate-core/src/air/trajectory/scoring.rs @@ -1,8 +1,6 @@ -//! TU3 scoring82D68FE8/82D68AF0/82D69790 for a world with no grind edges. -//! That topology is supplied by the authored BoardWorld, not inferred from a -//! failed collision. Every wall probe still queries real world geometry. +//! TU3 scoring82D68FE8/82D68AF0/82D69790 with world-supplied grind admission. use super::{ - Prediction, SelectorInput, SelectorSettings, SurfaceHit, launch::adjust_trajectory, math::*, + launch::adjust_trajectory, math::*, Prediction, SelectorInput, SelectorSettings, SurfaceHit, }; #[derive(Clone, Copy, Debug)] @@ -15,6 +13,7 @@ pub(super) struct Candidate { pub score: f32, pub wall_score: f32, pub wall_ride: bool, + pub grind: Option, } pub(super) fn score( candidates: &mut [Candidate], @@ -22,6 +21,7 @@ pub(super) fn score( adjusted_on_vert: bool, input: SelectorInput, s: &SelectorSettings, + mut grind: impl FnMut(&mut Prediction, bool) -> Result, mut line: impl FnMut(Vector, Vector, f32) -> Result, String>, ) -> Result { let mut all_miss = true; //selector9662, consumed by PhysicsAir COM latch @@ -30,6 +30,7 @@ pub(super) fn score( c.score = 0.0; c.wall_score = 0.0; c.wall_ride = false; + c.grind = None; if !c.prediction.result.valid() { continue; } @@ -53,13 +54,25 @@ pub(super) fn score( let force_score = (force_dot * s.score_landing_force) * scalar; let direction_score = (sideways * s.score_landing_direction) * scalar; - //82D69C00 returns0 immediately for the actual empty edge query. Its - //9552 flag remains clear.82D69D68 then evaluates the stock graph at - //its native invalid-query distance1000; do not substitute a score. - let grind_score = if input.flags_2476 & 0x0200_0000 == 0 - && (input.grind_lock_distance > 0.5 || !middle) - { - s.grind_penalty_vs_distance.evaluate(1000.0) + //82D69C00 attempts acquisition only on the first-pass centre trajectory. + let mut evaluation = grind( + &mut c.prediction, + middle + && !(input.flags_2472 & 0x2000_0000 != 0 + && input.offboard_flags_1776 & 0x0400_0000 != 0 + && input.offboard_flags_1776 & 0x0800_0000 == 0), + )?; + if adjusted_on_vert && input.directional_input < 0.5 && evaluation.score > 0.0 { + evaluation.target = None; + evaluation.score = s.grind_penalty_vs_distance.evaluate(0.0); + } + c.grind = evaluation.target; + let grind_score = if input.flags_2476 & 0x0200_0000 != 0 { + 0.0 + } else if evaluation.score != 0.0 { + evaluation.score + } else if input.grind_lock_distance > 0.5 || !middle { + s.grind_penalty_vs_distance.evaluate(evaluation.distance) } else { 0.0 }; diff --git a/crates/skate-core/src/air/trajectory/selector.rs b/crates/skate-core/src/air/trajectory/selector.rs index 796e6e16..12ee78da 100644 --- a/crates/skate-core/src/air/trajectory/selector.rs +++ b/crates/skate-core/src/air/trajectory/selector.rs @@ -1,9 +1,10 @@ //! Original selector Launch82D67848 and Update82D68800 with typed batch ownership. use super::{ - LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit, - Trajectory, launch, + launch, math::*, scoring::{self, Candidate}, + LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit, + Trajectory, }; ///The current authored world contains collision triangles and no grind edges. @@ -22,7 +23,8 @@ pub struct Selection { pub collision_position: Vector, //2688 pub com_trajectory: Trajectory, //2704 pub surface_category: u32, //9640 - pub wall_ride: bool, //9661 + pub wall_ride: bool, + pub grind: Option, //9661 } #[derive(Clone, Debug, Default)] pub struct TrajectorySelector { @@ -172,6 +174,7 @@ impl TrajectorySelector { score: 0.0, wall_score: 0.0, wall_ride: false, + grind: None, } }); for (index, c) in self @@ -199,6 +202,28 @@ impl TrajectorySelector { s: &SelectorSettings, _topology: WorldWithoutGrindEdges, line: impl FnMut(Vector, Vector, f32) -> Result, String>, + ) -> Result { + self.complete_batch_with_grinds( + results, + input, + s, + |_, _| { + Ok(super::grind::GrindEvaluation { + target: None, + score: 0.0, + distance: 1000.0, + }) + }, + line, + ) + } + pub fn complete_batch_with_grinds( + &mut self, + results: &[QueryResult], + input: SelectorInput, + s: &SelectorSettings, + grind: impl FnMut(&mut Prediction, bool) -> Result, + line: impl FnMut(Vector, Vector, f32) -> Result, String>, ) -> Result { self.just_changed = false; if !self.pending { @@ -220,6 +245,7 @@ impl TrajectorySelector { self.adjusted_on_vert, input, s, + grind, line, )?; let mut index = 0; @@ -250,6 +276,7 @@ impl TrajectorySelector { }), surface_category: self.selection.map_or(0, |v| v.surface_category), wall_ride: c.wall_ride, + grind: c.grind, }; if c.prediction.result.contact_time < s.minimum_valid_time { self.suggested_normal = Some(c.prediction.result.contact_normal); @@ -260,10 +287,17 @@ impl TrajectorySelector { } selection.surface_category = (c.prediction.result.surface >> 7) & 31; selection.com_trajectory = self.com_trajectory(selection, input, s); - //No grind edges means native9652/9653 are false. AIPath is absent from - //this player-owned runtime, so original9659 is false as well. - self.grind_locked_to_middle = false; - let second_pass = self.pass == 1 + //82D68C80: an accepted middle trajectory owns9653 and skips the second pass. + self.grind_locked_to_middle = index == 0 && c.grind.is_some(); + if let Some(target) = self.candidates.iter().take(count).find_map(|c| c.grind) { + //82D6AF30/82E0A120 publish the rail-derived upright plane normal. + self.grind_normal = Some(crate::physics::grind_contact::upright_normal(sub( + target.edge.end, + target.edge.start, + ))); + } + let second_pass = !self.grind_locked_to_middle + && self.pass == 1 && count >= 2 && !(angle_between(selection.landing_normal, input.ground_normal) < s.minimum_normal_delta_second_pass); diff --git a/crates/skate-core/src/physics/grind_air.rs b/crates/skate-core/src/physics/grind_air.rs new file mode 100644 index 00000000..127a70ad --- /dev/null +++ b/crates/skate-core/src/physics/grind_air.rs @@ -0,0 +1,338 @@ +//! TU3 GrindAirAdjust82D712E0: predicted board/contact selection and bounded pose correction. +use super::grind_contact::{control::rotate, Primitive}; +use super::skeleton_animation_record::{AnimationPartTransform as Frame, IDENTITY}; +use crate::riding::ground_correction_math::dot_product as dot; +type V = [f32; 4]; +const UP: V = [0., 1., 0., 0.]; +const DEG: f32 = core::f32::consts::PI / 180.0; +#[derive(Clone, Debug)] +pub struct Settings { + pub frames: usize, + pub stomp: f32, + pub points: [V; 5], + pub ranges: [f32; 7], + pub distances: [f32; 7], + pub yaw_assist: [f32; 7], + pub max_offset: f32, + pub max_delta: f32, + pub max_angle: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Target { + pub edge: Primitive, + pub limits: [V; 3], +} +#[derive(Clone, Copy, Debug)] +pub struct Adjustment { + pub axis: V, + pub offset: V, + pub angles: V, +} +#[derive(Default)] +pub struct GrindAir { + pub target: Option, + offset_delta: V, + offset: V, + angle_delta: V, + angles: V, + previous_kind: Option, +} +impl GrindAir { + ///Start82D34FE8 clears retained displacement and angle integrators. + pub fn start(&mut self, target: Target) { + *self = Self { + target: Some(target), + ..Self::default() + }; + } + pub fn update( + &mut self, + board: Frame, + velocity: V, + angular_velocity: V, + up: V, + dt: f32, + flags: [u32; 3], + inverted: bool, + s: &Settings, + ) -> Option { + let target = self.target?; + //82D712E0 suppression: Processed2480 bit26,2472 bit15,2468 bit3. + if flags[0] & 0x0400_0000 != 0 || flags[1] & 0x8000 != 0 || flags[2] & 8 != 0 { + self.offset = [0.; 4]; + self.angles = [0.; 4]; + return None; + } + let rail = unit(sub(target.edge.end, target.edge.start)); + let normal = unit(cross(rail, cross(up, rail))); + let count = s.frames; + if count < 2 { + return None; + } + //82D71430 integrates physical angular velocity and native gravity/stomp. + + let mut samples = Vec::with_capacity(count); + let mut headings = Vec::with_capacity(count); + let mut current = board; + let mut vel = velocity; + let speed = length(angular_velocity); + let axis = unit(angular_velocity); + let acceleration = add([0., -9.8, 0., 0.], scale(up, s.stomp)); + for _ in 0..count { + let points = s.points.map(|p| point(current, p)); + let forward = sub(points[1], points[4]); + let flat = unit(sub(forward, scale(normal, dot(forward, normal)))); + let aligned = if dot(flat, rail) < 0.0 { + scale(flat, -1.0) + } else { + flat + }; + let mut angle = crate::trigonometry::acos(dot(aligned, rail).clamp(-1.0, 1.0)); + if angle.abs() > core::f32::consts::FRAC_PI_2 { + angle = core::f32::consts::PI - angle; + } + let sign = if dot(normal, cross(aligned, rail)) < 0.0 { + -1.0 + } else { + 1.0 + }; + headings.push(-sign * angle / DEG); + samples.push(points); + if speed >= f32::from_bits(0x37800000) { + for column in &mut current[..3] { + *column = rotate(axis, *column, speed * dt); + } + } + vel = add(vel, scale(acceleration, dt)); + current[3] = add(current[3], scale(vel, dt)); + } + //82D721A0: first strict plane crossing for each of the five deck probes. + let mut times = [-1.0; 5]; + let mut distances = [0.0; 5]; + let mut corrections = [[0.; 4]; 5]; + let mut earliest = count as f32; + for probe in 0..5 { + for i in 1..count { + let a = dot(normal, sub(samples[i - 1][probe], target.edge.start)); + let b = dot(normal, sub(samples[i][probe], target.edge.start)); + if a * b < 0.0 { + let fraction = (a / (a - b)).abs(); + let time = (i - 1) as f32 + fraction; + let at = add( + scale(samples[i - 1][probe], 1.0 - fraction), + scale(samples[i][probe], fraction), + ); + let on = add( + target.edge.start, + scale(rail, dot(sub(at, target.edge.start), rail)), + ); + let correction = sub(on, at); + times[probe] = time; + distances[probe] = length(correction); + corrections[probe] = correction; + earliest = earliest.min(time); + break; + } + } + } + //82D723D0/82D724E8. Entries0..6: inverted, tips, deck, both trucks, each truck. + let probes = [0, 1, 4, 0, 2, 2, 3]; + let desired = [90., 90., 90., 90., 0., 0., 0.]; + let valid = |kind: usize| { + let p = probes[kind]; + let t = times[p]; + if kind == 4 { + if times[2].min(times[3]) < 4.0 && (times[2] - times[3]).abs() > 2.0 { + return false; + } + } else if earliest < 5.0 && (earliest - t).abs() > 3.0 { + return false; + } + t > 0.0 + && distances[p] < s.distances[kind] + && (desired[kind] - headings[(t + 0.5) as usize].abs()).abs() < s.ranges[kind] + }; + let mut selected = None; + for mut kind in 0..7 { + if inverted != (kind == 0) || !valid(kind) { + continue; + } + if kind == 3 { + if let Some(previous @ (1 | 2)) = self.previous_kind { + if valid(previous) { + kind = previous; + } + } + } + if selected.is_none_or(|old: usize| distances[probes[kind]] < distances[probes[old]]) { + selected = Some(kind); + } + if selected.is_some_and(|k| k >= 5) { + break; + } + } + let kind = selected?; + self.previous_kind = Some(kind); + let p = probes[kind]; + let time = times[p]; + let sample = (time + 0.5) as usize; + //82D72610: integrate correction/time; bound each step and total offset. + self.offset_delta = limited( + add(self.offset_delta, scale(corrections[p], 1.0 / time)), + s.max_delta, + ); + self.offset = limited(add(self.offset, self.offset_delta), s.max_offset); + //82D72810: native angle integrators use degrees for the yaw error only. + let heading = headings[sample]; + let desired = if heading > 0.0 { + desired[kind] + } else { + -desired[kind] + }; + let speed = + ((desired - heading) / time * s.yaw_assist[kind]).clamp(-s.max_angle, s.max_angle); + self.angle_delta[1] = + (self.angle_delta[1] / DEG + speed).clamp(-s.max_angle, s.max_angle) * DEG; + let forward = sub(samples[sample][1], samples[sample][4]); + let across_board = unit(sub(forward, scale(rail, dot(forward, rail)))); + let roll_error = match kind { + 0 => { + let v = signed_angle(across_board, target.limits[0], rail); + if v > core::f32::consts::FRAC_PI_2 { + v - core::f32::consts::PI + } else if v < -core::f32::consts::FRAC_PI_2 { + v + core::f32::consts::PI + } else { + v + } + } + 1 | 2 | 3 => { + let from = if kind == 1 { + scale(across_board, -1.0) + } else { + across_board + }; + let a = signed_angle(from, target.limits[1], rail); + let b = signed_angle(from, target.limits[2], rail); + if a.abs() < b.abs() { + a + } else { + b + } + } + _ => 0.0, + }; + self.angle_delta[0] = + (self.angle_delta[0] + roll_error / time).clamp(-s.max_angle * DEG, s.max_angle * DEG); + //82D71BB0: projection of effective deck-right relative to support/up. + let projected = unit(board[0].map(|v| v - dot(board[0], normal))); + let pitch_error = if dot(projected, projected) > 0.9 { + signed_angle(projected, normal, board[2]) * 0.9 + } else { + 0.0 + }; + self.angle_delta[2] = if matches!(kind, 0 | 1 | 2 | 3) { + pitch_error / time + } else { + 0.0 + }; + self.angles = add(self.angles, self.angle_delta); + Some(Adjustment { + axis: rail, + offset: self.offset, + angles: self.angles, + }) + } +} +impl Adjustment { + ///82BDCFB0 writes the existing15-frame board/IK offset owner15696. + pub fn local_transform( + self, + animation_to_world: Frame, + board_position: V, + physical_forward: V, + ) -> Frame { + let across = unit(cross(UP, self.axis)); + let up = unit(cross(self.axis, across)); + let rotation = |v| { + rotate( + up, + rotate( + self.axis, + rotate(physical_forward, v, self.angles[2]), + self.angles[0], + ), + self.angles[1], + ) + }; + let mut world = IDENTITY; + for i in 0..3 { + world[i] = rotation(IDENTITY[i]); + } + world[3] = sub(add(board_position, self.offset), rotation(board_position)); + let inverse = inverse(animation_to_world); + super::skeleton_animation_record::compose_affine( + &inverse, + &super::skeleton_animation_record::compose_affine(&world, &animation_to_world), + ) + } +} +fn inverse(f: Frame) -> Frame { + let mut r = IDENTITY; + for i in 0..3 { + for j in 0..3 { + r[i][j] = f[j][i]; + } + } + r[3] = [-dot(f[0], f[3]), -dot(f[1], f[3]), -dot(f[2], f[3]), 0.]; + r +} +fn point(f: Frame, v: V) -> V { + super::skeleton_animation_record::transform_point(&f, v) +} +fn signed_angle(a: V, b: V, axis: V) -> f32 { + let a = unit(a); + let b = unit(b); + let angle = crate::trigonometry::acos(dot(a, b).clamp(-1.0, 1.0)); + if dot(cross(a, b), axis) < 0.0 { + -angle + } else { + angle + } +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +fn scale(v: V, s: f32) -> V { + v.map(|v| v * s) +} +fn length(v: V) -> f32 { + dot(v, v).sqrt() +} +fn unit(v: V) -> V { + let l = length(v); + if l > 0.000001 { + scale(v, 1.0 / l) + } else { + [0.; 4] + } +} +fn limited(v: V, max: f32) -> V { + let l = length(v); + if l > max { + scale(v, max / l) + } else { + v + } +} +fn cross(a: V, b: V) -> V { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + 0., + ] +} diff --git a/crates/skate-core/src/physics/mod.rs b/crates/skate-core/src/physics/mod.rs index 411821aa..3307ca81 100644 --- a/crates/skate-core/src/physics/mod.rs +++ b/crates/skate-core/src/physics/mod.rs @@ -71,3 +71,5 @@ pub mod skeleton_landing; pub mod skeleton_motion; pub mod skeleton_output; pub mod skeleton_root; + +pub mod grind_air; diff --git a/crates/skate-game/src/physics/air_trajectory/grind.rs b/crates/skate-game/src/physics/air_trajectory/grind.rs new file mode 100644 index 00000000..85231c4f --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/grind.rs @@ -0,0 +1,165 @@ +//! Live TU3 trajectory-to-spline query82D69C00 and admission82D6A840. +use skate_core::{ + air::trajectory::{grind::*, Prediction}, + physics::{board_world::BoardWorld, grind_contact::Primitive}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +type V = [f32; 4]; +pub(super) struct Settings { + pub limits: GrindAssistLimits, + height: PointGraph<8>, + padding: f32, + maximum_adjust: f32, + velocity_scalar: f32, + max_angle: f32, + score: f32, + truck_distance: f32, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let t = |key| data.float("physics_trajectory", "default", key); + Ok(Self { + limits: GrindAssistLimits { + lock_distance: 0.0, + max_speed_squared_ledge: t("GrindMaxSpeedSqrIntoLedge")?, + max_speed_squared_rail: t("GrindMaxSpeedSqrIntoGrind")?, + max_downward_speed: t("GrindMaxSpeedDownOntoGrind")?, + ledge_scalars: [ + t("GrindLockLedgeScalar2")?, + t("GrindLockLedgeScalar")?, + t("GrindLockLedgeLowSideScalar2")?, + t("GrindLockLedgeLowSideScalar")?, + ], + tip_scalar: t("GrindTipScalar")?, + maximum_adjust_angle: t("GrindAdjustMaxAngle")?, + deck_dimensions: [ + data.float("physicsdeck", "default", "DeckMidLength")?, + data.float("physicsdeck", "default", "DeckFrontEndSize")?, + ], + }, + height: super::settings::graph8( + data, + "physics_trajectory", + "RequiredAngleVsHeight", + true, + )?, + padding: t("GrindOffset")?, + maximum_adjust: t("MaxTrajectoryAdjust")?, + velocity_scalar: t("GrindLandingVelScalar")?, + max_angle: t("GrindLandingMaxAngle")?, + score: t("ScoreGrind")?, + truck_distance: data.float("physics_grinds", "default", "DeckCenterToTruck")?, + }) + } + pub fn evaluate( + &self, + prediction: &mut Prediction, + acquire: bool, + world: &BoardWorld, + edges: &[Primitive], + nearby: &mut Vec, + lock_distance: f32, + com: V, + board_position: V, + ) -> Result { + let velocity = prediction.collision_velocity(); + let landing = prediction + .request + .trajectory + .position_at(prediction.result.contact_time); + if acquire { + *nearby = edges + .iter() + .copied() + .filter(|e| { + (0..3).all(|i| { + e.start[i].min(e.end[i]) <= landing[i] + if i == 1 { 4.0 } else { 2.0 } + && e.start[i].max(e.end[i]) + >= landing[i] - if i == 1 { 0.5 } else { 2.0 } + }) + }) + .take(40) + .collect(); + } + let landing = prediction.result.contact_position; + let mut distance: f32 = 1000.0; + for e in nearby.iter() { + let delta = sub(e.end, e.start); + let len = dot(delta, delta); + let from = sub(landing, e.start); + let projected = sub(from, delta.map(|v| v * dot(from, delta) / len)); + distance = distance.min( + dot(projected, projected) + .sqrt() + .min(dot(from, from).sqrt()) + .min(dot(sub(landing, e.end), sub(landing, e.end)).sqrt()), + ); + } + if acquire { + let mut candidates = nearby + .iter() + .enumerate() + .filter_map(|(i, e)| consider_grind_primitive(*prediction, *e, i, self.padding)) + .collect(); + let mut limits = self.limits.clone(); + limits.lock_distance = lock_distance; + while let Some(c) = take_best_grind(&mut candidates, lock_distance, &self.height) { + let e = nearby[c.primitive]; + let Some(surface) = skate_core::air::trajectory::grind_surface::investigate( + world, + e.start, + e.end, + c.point, + self.truck_distance, + )? + else { + continue; + }; + let Some(delta) = admitted_displacement( + *prediction, + c, + e, + velocity, + com, + surface.evidence, + &limits, + ) else { + continue; + }; + apply_admitted_target( + prediction, + c, + delta, + surface.normal, + velocity, + self.maximum_adjust, + self.velocity_scalar, + self.max_angle, + ); + bevy::log::info!("GRIND_ASSIST lock_distance={} miss_distance={} time={} rail={} correction={:?}",lock_distance,c.distance,c.time,e.owner,delta); + return Ok(GrindEvaluation { + target: Some(GrindTarget { + edge: e, + point: c.point, + normal: surface.normal, + air_limits: surface.air_limits(e.start, e.end, c.point, board_position), + }), + score: self.score, + distance, + }); + } + } + Ok(GrindEvaluation { + target: None, + score: 0.0, + distance, + }) + } +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn dot(a: V, b: V) -> f32 { + skate_core::riding::ground_correction_math::dot_product(a, b) +} diff --git a/crates/skate-game/src/physics/air_trajectory/mod.rs b/crates/skate-game/src/physics/air_trajectory/mod.rs index e7cb2dcb..49d3f670 100644 --- a/crates/skate-game/src/physics/air_trajectory/mod.rs +++ b/crates/skate-game/src/physics/air_trajectory/mod.rs @@ -1,10 +1,11 @@ //! Real stock-settings and BoardWorld adapter for the player trajectory selector. +mod grind; mod settings; mod world; use skate_core::{ air::trajectory::{ - LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, TrajectorySelector, - query_trajectory, + query_trajectory, LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, + TrajectorySelector, }, physics::board_world::BoardWorld, }; @@ -13,6 +14,11 @@ use skate_data::collections::Collections; pub struct AirTrajectoryRuntime { pub selector: TrajectorySelector, pub settings: SelectorSettings, + pub grind_board_position: [f32; 4], + pub grind_com_position: [f32; 4], + pub edges: Vec, + grind_candidates: Vec, + grind_settings: grind::Settings, pending_results: Option>, } impl AirTrajectoryRuntime { @@ -29,6 +35,11 @@ impl AirTrajectoryRuntime { selector: TrajectorySelector::new(), settings: settings::load(collections)?, pending_results: None, + edges: Vec::new(), + grind_board_position: [0.; 4], + grind_com_position: [0.; 4], + grind_settings: grind::Settings::load(collections)?, + grind_candidates: Vec::new(), }) } pub fn launch( @@ -47,11 +58,22 @@ impl AirTrajectoryRuntime { let Some(results) = self.pending_results.take() else { return Ok(self.selector.update_without_completion()); }; - let valid = self.selector.complete_batch( + let valid = self.selector.complete_batch_with_grinds( &results, input, &self.settings, - world::topology(world), + |prediction, acquire| { + self.grind_settings.evaluate( + prediction, + acquire, + world, + &self.edges, + &mut self.grind_candidates, + input.grind_lock_distance, + self.grind_com_position, + self.grind_board_position, + ) + }, |start, end, radius| world::line(world, start, end, radius), )?; if self.selector.pending() { diff --git a/crates/skate-game/src/physics/air_trajectory/settings.rs b/crates/skate-game/src/physics/air_trajectory/settings.rs index f34f6a79..bd2a3ec9 100644 --- a/crates/skate-game/src/physics/air_trajectory/settings.rs +++ b/crates/skate-game/src/physics/air_trajectory/settings.rs @@ -80,7 +80,7 @@ pub(super) fn load(data: &Collections) -> Result { vert_jump_align_max_angle: r("VertJumpAlignMaxAngle")?, //723FF102068622CF }) } -fn graph8( +pub(super) fn graph8( data: &Collections, class: &str, name: &str, diff --git a/crates/skate-game/src/physics/air_trajectory/world.rs b/crates/skate-game/src/physics/air_trajectory/world.rs index 0912a8cd..8970d2ed 100644 --- a/crates/skate-game/src/physics/air_trajectory/world.rs +++ b/crates/skate-game/src/physics/air_trajectory/world.rs @@ -2,11 +2,11 @@ //!82772028, across the host's world-format boundary. BoardWorld stores static //!world-space triangles, so their source geometry transform is identity. use skate_core::{ - air::trajectory::{SurfaceHit, WorldWithoutGrindEdges}, + air::trajectory::SurfaceHit, math::Vector3, physics::{ board_world::BoardWorld, - triangle_query::{TriangleLineHit, triangle_segment}, + triangle_query::{triangle_segment, TriangleLineHit}, }, }; type Vector = [f32; 4]; @@ -17,13 +17,6 @@ const IDENTITY: [[f32; 4]; 4] = [ [0.0; 4], ]; -///BoardWorld's authored format contains triangles only; no grind primitive -///collection is present. Update this adapter together with any world format -///extension that introduces actual grind edges. -pub(super) fn topology(_world: &BoardWorld) -> WorldWithoutGrindEdges { - WorldWithoutGrindEdges -} - pub(super) fn line( world: &BoardWorld, start: Vector, diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 2fb86d39..2ff24b86 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -28,6 +28,7 @@ pub(super) fn advance( //Publishing Ground here runs its ForcePhysics Begin before the initial //input reset82DB8998, which would immediately clear that mode again. } + if skater.trajectory.edges.is_empty() { skater.trajectory.edges.clone_from(&physics.grind.primitives); } physics.board.clear_forces(); let query_timer = crate::performance::Scope::new("wheel_and_foot_queries"); //World8275EA20 starts both batches before actor SetUpPhysics. Foot @@ -63,6 +64,8 @@ pub(super) fn advance( actions, input_available, )?; + skater.trajectory.grind_board_position = solve::deck_frame(&physics.board)[3]; + skater.trajectory.grind_com_position = skater.player_input.processed.vectors_544_560_592_608[2].map(f32::from_bits); skater.ground_settings = skater.ground_profiles.select( skater.player_input.processed.state_variant_index_2528, skater.player_input.processed.surface_mode_2540, diff --git a/crates/skate-game/src/physics/grind.rs b/crates/skate-game/src/physics/grind.rs index 5b6c486c..0b0903e0 100644 --- a/crates/skate-game/src/physics/grind.rs +++ b/crates/skate-game/src/physics/grind.rs @@ -23,7 +23,7 @@ mod camera; mod chromosome; pub(crate) struct Runtime { camera: camera::GrindCamera, - primitives: Vec, + pub(super) primitives: Vec, pub candidate: Option, pub name: String, pub active: bool, diff --git a/crates/skate-game/src/physics/grind_air_settings.rs b/crates/skate-game/src/physics/grind_air_settings.rs new file mode 100644 index 00000000..38dad30b --- /dev/null +++ b/crates/skate-game/src/physics/grind_air_settings.rs @@ -0,0 +1,67 @@ +//! Stock GrindAirAdjust settings82D72B40; lengths from the actual deck/truck collections. +use skate_core::physics::grind_air::Settings; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result { + let air = |key| data.float("physics_grinds_air", "default", key); + let deck = |key| data.float("physicsdeck", "default", key); + let half = deck("DeckMidLength")? * 0.5; + let tip = deck("DeckFrontEndSize")? * air("TipOffsetFraction")?; + let angle = deck("DeckFrontEndAngle")? * core::f32::consts::PI / 180.0; + let z = half + data.float("physicstrucks", "default", "TruckZPosFront")?; + let y = data.float("physicstrucks", "default", "TruckYPos")?; + let frames = data.integer("physics_grinds_air", "default", "NumFramesToTest")? as usize; + if !(2..=12).contains(&frames) { + return Err("GrindAir NumFramesToTest exceeds native12-frame storage".into()); + } + Ok(Settings { + frames, + stomp: air("StompAccelerationAdjust")?, + points: [ + [0.; 4], + [ + 0., + tip * skate_core::trigonometry::sin_cos(angle).0, + half + tip, + 0., + ], + [0., y, z, 0.], + [0., y, -z, 0.], + [ + 0., + tip * skate_core::trigonometry::sin_cos(angle).0, + -half - tip, + 0., + ], + ], + ranges: [ + 90., + air("AngleRangeTipSlide")?, + air("AngleRangeTipSlide")?, + air("AngleRangeBoardSlide")?, + air("AngleRange50_50")?, + air("AngleRange5_0")?, + air("AngleRange5_0")?, + ], + distances: [ + z, + z * air("MaxDistTipSlide")?, + z * air("MaxDistTipSlide")?, + z * air("MaxDistBoardSlide")?, + z * air("MaxDist50_50")?, + z * air("MaxDist5_0")?, + z * air("MaxDist5_0")?, + ], + yaw_assist: [ + air("AngleYAssistBoardSlide")?, + air("AngleYAssistTipSlide")?, + air("AngleYAssistTipSlide")?, + air("AngleYAssistBoardSlide")?, + air("AngleYAssist50_50")?, + air("AngleYAssist5_0")?, + air("AngleYAssist5_0")?, + ], + max_offset: air("MaxOffsetDist")?, + max_delta: air("MaxOffsetDeltaPerFrame")?, + max_angle: air("MaxAngleDeltaPerFrame")?, + }) +} diff --git a/crates/skate-game/src/physics/known_air/runtime.rs b/crates/skate-game/src/physics/known_air/runtime.rs index b43b66a5..ba882304 100644 --- a/crates/skate-game/src/physics/known_air/runtime.rs +++ b/crates/skate-game/src/physics/known_air/runtime.rs @@ -127,11 +127,14 @@ impl KnownAirRuntime for Runtime<'_> { self.skater.trajectory.selector.grind_locked_to_middle() } fn start_grind_air_adjust_from_selector(&mut self) { - //The host BoardWorld has an explicitly empty edge collection. Its selector - //cannot produce9653; do not manufacture the required2944/2960/edge9568 data. - self.record(Err( - "KnownAir received a grind selection from an edge-free world".into(), - )); + if let Some(target)=self.selection.grind { + self.skater.skeleton_input.grind_air.start(skate_core::physics::grind_air::Target { + edge:target.edge,limits:target.air_limits, + }); + self.skater.skeleton_input.grind_air_started=true; + } else { + self.record(Err("KnownAir grind target lost its native spline record".into())); + } } fn selector_closest_trajectory_point(&mut self, com: V, zero: V) -> (i32, V) { let t = self.selection.com_trajectory; diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index eef4621f..a7e55846 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -231,6 +231,7 @@ impl SkaterRuntime { player_input: PlayerInputRuntime::load(&data, NoGrindEdges)?, player_state: super::player_state::PlayerState::load(&data, mode)?, skeleton_input: SkeletonInputRuntime { + grind_air_settings: Some(SkeletonInputRuntime::load_grind_settings(&data)?), drive_frames: initial_parts, extra_target_positions: [ initial_targets.com, diff --git a/crates/skate-game/src/physics/skeleton_input_runtime.rs b/crates/skate-game/src/physics/skeleton_input_runtime.rs index e4634cc2..df211934 100644 --- a/crates/skate-game/src/physics/skeleton_input_runtime.rs +++ b/crates/skate-game/src/physics/skeleton_input_runtime.rs @@ -3,6 +3,8 @@ //! GeneralUpdate belongs to the later movement-state phase, not PlayerInput. #[path = "skeleton_input_general.rs"] mod general; +#[path = "grind_air_settings.rs"] +mod grind_settings; #[path = "skeleton_input_teleport.rs"] mod teleport; @@ -10,7 +12,7 @@ use super::{ animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, }; use skate_core::{ - animation::output::{NativeMatrix, attributes::AnimationAttribute}, + animation::output::{attributes::AnimationAttribute, NativeMatrix}, input::controller::ActionMap, physics::{ board::BodyId, @@ -77,6 +79,8 @@ pub(crate) struct SkeletonInputRuntime { ///GrindAirAdjust ctor82D7116C initializes activated289=false. ///Only KnownAir Update82D35D30/Exit82D3597C subsequently write this flag. ///288, ctor82D71160; activated289 is a separate retained flag. + pub grind_air: skate_core::physics::grind_air::GrindAir, + pub grind_air_settings: Option, pub grind_air_started: bool, pub grind_air_active: bool, pub grind_air_adjusting: bool, @@ -100,6 +104,8 @@ impl Default for SkeletonInputRuntime { force_mode: 0, head_tracking_history: [[0.0; 4]; 8], head_tracking_active: false, + grind_air: Default::default(), + grind_air_settings: None, grind_air_started: false, grind_air_active: false, grind_air_adjusting: false, @@ -108,6 +114,11 @@ impl Default for SkeletonInputRuntime { } impl SkeletonInputRuntime { + pub fn load_grind_settings( + data: &skate_data::collections::Collections, + ) -> Result { + grind_settings::load(data) + } ///Called by PlayerInputCallbacks::process_skeleton, after the actual ///BoardToolkit and line tests have been published. It does not advance ///Ground, rebuild a toolkit or submit a second physical simulation. @@ -154,9 +165,7 @@ impl SkeletonInputRuntime { //These authored offset producers are separate from Ground riding. //Do not silently reuse a riding pose if a future state enables them. let reparented_hands = owners.animated.reparented_hand_indices(); - super::offboard::pose_adjust::update( - owners.animated, pose.globals, reparented_hands, p, - )?; + super::offboard::pose_adjust::update(owners.animated, pose.globals, reparented_hands, p)?; if p.flags_2476 & 0x100 != 0 { if p.flags_2476 & 0x10000 == 0 { return Err( @@ -168,7 +177,46 @@ impl SkeletonInputRuntime { //Inactive Ground leaves retained total offset/angle untouched. self.grind_air_adjusting = false; if self.grind_air_active { - return Err("Active air grind adjustment requires82D712E0 geometric stages".into()); + let settings = self + .grind_air_settings + .as_ref() + .ok_or("Missing stock GrindAir settings")?; + let mut frame = super::solve::deck_frame(board); + if p.flags_2468 & 0x0010_0000 != 0 { + frame[0] = frame[0].map(|v| -v); + frame[2] = frame[2].map(|v| -v); + } + let mapped = skate_core::physics::skeleton_animation_record::map_animation_parts( + pose.globals, + &owners.animated.bone_indices, + &owners.animated.physics_frames, + )?; + skate_core::physics::skeleton_motion::SkeletonMotion::publish_unadjusted_board( + &mapped[0], + &mut p.flags_2472, + ); + if let Some(adjustment) = self.grind_air.update( + frame, + p.vectors_400_416[0].map(f32::from_bits), + p.vectors_720_784_800_816_832_864[0].map(f32::from_bits), + p.vectors_544_560_592_608[0].map(f32::from_bits), + p.timestep_2604, + [p.flags_2480, p.flags_2472, p.flags_2468], + p.flags_2484 & 0x0020_0000 != 0, + settings, + ) { + let pivot = skate_core::physics::skeleton_animation_record::transform_point( + &owners.animated.roots.animation_to_world, + mapped[0][3], + ); + let offset = adjustment.local_transform( + owners.animated.roots.animation_to_world, + pivot, + owners.animated.board_frames.physical_board[2], + ); + owners.animated.board_offset.refresh_transform(offset); + self.grind_air_adjusting = true; + } } } let up = p.vectors_544_560_592_608[0].map(f32::from_bits); diff --git a/docs/grind-families.md b/docs/grind-families.md index 6bb7bb56..d438d3b5 100644 --- a/docs/grind-families.md +++ b/docs/grind-families.md @@ -45,11 +45,13 @@ extracts are kept locally under logs/biped-*.txt. All six contact families and their named variations are connected for a manual playability pass. This is not verified retail parity. Existing host limitations -remain: trajectory-driven magnetic acquisition, specialized tipslide drop-in -exit handling, full native material/engagement feedback, and the air439 history +remain: specialized tipslide drop-in exit handling, full native +material/engagement feedback, and the air439 history snapshot are not completely connected. Common pop/exit behavior still supplies those families in this adapter. The ground history supplies approach naming; short approaches and rapid re-grinds particularly need visual comparison. Only compilation and source review were performed, as requested. Gameplay and animation behavior are for the user's visual test on the default map. + +Trajectory-driven magnetic acquisition is now connected; see [magnetism](grind-magnetism-difficulty.md). diff --git a/docs/grind-magnetism-difficulty.md b/docs/grind-magnetism-difficulty.md index d0d50a9e..73ec6680 100644 --- a/docs/grind-magnetism-difficulty.md +++ b/docs/grind-magnetism-difficulty.md @@ -9,9 +9,9 @@ Observed in assets/private/stock/skater-collections.json, physics_mode: | hardcore | 3E19999A | 0.15 | The field is native physics_mode+80. The live SelectorInput already receives -this field when the active difficulty changes. It does not currently result in -magnetic acquisition because AirTrajectoryRuntime passes WorldWithoutGrindEdges, -and KnownAir's StartGrindAirAdjust callback reports an unsupported owner. +this field when the active difficulty changes. AirTrajectoryRuntime now queries +the authored spline primitives and the selected result retains the accepted +primitive and angular reference directions through KnownAir entry. Observed TU3 path: ScoreTrajectories82D68FE8 attempts82D69C00 on the first-pass middle trajectory. Its actual spline query uses bounds around the natural landing @@ -32,9 +32,30 @@ from the difficulty-dependent initial capture distance. Native source identity: TU3 default_82000000_011B0000.bin SHA256 f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4. -Instruction extracts are in local logs/biped-*.txt. No gameplay tests or reference -process were run. Experimental selector plumbing is saved in -logs/magnetism-in-progress, outside the production source: the incomplete pose -owner must be connected before exposing9653 in the playable game. +Instruction extracts are in local logs/biped-*.txt. -Status: findings confirmed statically; magnetism is NOT fixed in the staged game. +## Live integration + +The centre-trajectory query supplies ranking, admission, trajectory displacement +and the landing normal. Alternative candidates reuse its actual edge collection +for the distance penalty; they do not issue independent replacement queries. +The selector retains the accepted middle target and skips its normal-delta +second pass exactly at that handoff. The current processed COM and board position, +predicted collision velocity and active difficulty feed the admission checks. + +KnownAir starts the retained GrindAir owner and activates it at the original +apex/remaining-frame boundary. It predicts the physical deck's rotation and +translation, intersects five deck/truck points with the rail plane, chooses from +seven native contact choices, and integrates bounded translation and angular +corrections. ProcessData publishes the unadjusted board orientation flag before +this decision. The result refreshes the existing board/IK offset owner; ordinary +exit deactivates the owner and preserves the native offset decay. + +`GRIND_ASSIST` log entries record accepted capture distance, miss distance, +predicted contact time, primitive owner and correction. These are passive output +from the user's play session; no scripted inputs are sent. + +Status: implemented and compiled, ready for the user's gameplay test. No tests +or reference process were run, as requested. The equations and constants were +translated from static TU3 source; matched retail replay/bitwise parity has not +been established. The current world adapter supplies static spline primitives. diff --git a/docs/grind-native-assist.md b/docs/grind-native-assist.md index 807b53fb..07a7e9f6 100644 --- a/docs/grind-native-assist.md +++ b/docs/grind-native-assist.md @@ -1,59 +1,15 @@ -# Native grind assist port status +# Native grind assist -The live game now supports paired-truck50-50 and centre-deck boardslide contacts -(see boardslide-port.md). The new -`skate-core/src/air/trajectory/grind.rs` leaves are not connected to gameplay. -They must not be presented as working magnetic assistance or all-grind support. +The live acquisition and airborne board-adjustment path is now connected. +See [difficulty and integration details](grind-magnetism-difficulty.md) and +[grind families](grind-families.md). + +Core trajectory leaves82D6A398/82D6A168/82D6A840 are supplied with the real spline +collection and indexed collision investigation. Accepted targets pass through +selector scoring and KnownAir into GrindAirAdjust82D712E0. Difficulty uses the +stock physics_mode80 field, not a new snapping radius. Reference: TU3 image SHA256 f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4, -generated PPC in the local Skate3Research/Skate3CustomEngineLayer checkout. -No reference process or gameplay tests were run for this port. - -## Ported leaves - -- 82D60C80/82D60B98: quadratic trajectory-plane intersection and later-root selection. -- 82D6A398: projected rail trajectory candidate, signed endpoint interval, - radius plus GrindOffset plane, miss distance, crossing time, fixed60Hz frame, - rail direction and approach direction. -- 8296EC98/82E09C80: oriented approach angle and fractional-turn fold. -- 82D6A168: height/angle ranking inside the difficulty lock distance and - distance fallback outside it; removing one winner preserves retry order. -- Admission/displacement portion of82D6A840: relative height, vertical rail, - perpendicular speed, ledge-side lock scalars, deck-tip allowance and maximum - trajectory angle. It requires a supplied native surface result; no dummy - classification or unconditional snap is provided. - -These are static source translations, not a claim of verified bitwise parity. -The core crate builds. The user has reserved gameplay testing for themselves. - -## Missing live dependencies - -1. Preserve the complete indexed world-query result set for82C20728/82C20C08, - then implement the rail/edge/ledge classification82C21930. The existing - ground hang-up adapter only produces the fields that its ground caller reads. -2. Complete82D6A840's accepted-target publication and82D6AF58 landing-normal - construction. Feed the real result through trajectory scoring/second-pass - selection instead of the current WorldWithoutGrindEdges marker. -3. Connect GrindAirAdjust82D712E0 and its projection, target, and correction - owners to the physical skeleton/board. The displacement leaf by itself is - not the complete airborne assist. -4. Port the other contact probes/scorers and state owners. Native manager - 82D875A8 uses separate centre-deck, inverted-deck, truck and tip probes. - State400 boardslide,40150-50,402 tipslide,4035-0,404 backslash and405 - darkslide are distinct physics states. Naming variants requires the actual - chromosome producer as well as those state IDs. - -## Proposed read-only reference trace - -A reference run was authorized and launched, but its controller backend did -not expose the physical controller. The user requested continuation from code. -No successful reference grind capture was obtained and no synthetic gameplay -input was sent. - -Capture query inputs and indexed hit validity/fraction/position/normal/surface -at82C20728 and82C20C08, the classification at82C21930, ranked candidates and -accepted/rejected attempts at82D6A168/82D6A840, and GrindAirAdjust output at -82D712E0. Include state/category, mode, simulation tick, spline ID, board frame -and controller inputs so these observations can be matched to their callers. -Keep the trace local and do not modify the reference binaries or database. +matching local Skate3CustomEngineLayer generated PPC. Static source translation +and compilation only; gameplay validation is reserved for the user.