diff --git a/crates/skate-core/src/air/ground_jump/mod.rs b/crates/skate-core/src/air/ground_jump/mod.rs index 55b12e66..cb317c15 100644 --- a/crates/skate-core/src/air/ground_jump/mod.rs +++ b/crates/skate-core/src/air/ground_jump/mod.rs @@ -14,7 +14,8 @@ pub fn calculate( mode: GroundJumpMode, s: &GroundJumpSettings, ) -> GroundJump { - if input.flags_2468 & 0x0040_0000 == 0 { + let hippy = input.flags_2480 & 0x0000_1000 != 0; + if input.flags_2468 & 0x0040_0000 == 0 && !hippy { return GroundJump::default(); } let normal = input.filtered_ground_normal; @@ -32,7 +33,9 @@ pub fn calculate( value[1] += 1.0; value } else { - let minimum = if input.flags_2484 & 0x800 != 0 { + let minimum = if hippy { + s.hippy_minimum_height + } else if input.flags_2484 & 0x800 != 0 { mode.minimum_height_64 } else { mode.minimum_height_68 @@ -43,7 +46,8 @@ pub fn calculate( s.absolute_minimum_height, ); let high = s.maximum_height_vs_speed.evaluate(speed_fraction).mul_add( - mode.maximum_height - s.absolute_minimum_height, + if hippy { s.hippy_maximum_height } else { mode.maximum_height } + - s.absolute_minimum_height, s.absolute_minimum_height, ); let height = low.mul_add(1.0 - input.jump_strength, high * input.jump_strength); diff --git a/crates/skate-core/src/air/ground_jump/types.rs b/crates/skate-core/src/air/ground_jump/types.rs index 9332f049..f0425fcd 100644 --- a/crates/skate-core/src/air/ground_jump/types.rs +++ b/crates/skate-core/src/air/ground_jump/types.rs @@ -23,10 +23,15 @@ pub struct GroundJumpSettings { pub adjust_z_factor: f32, //432 pub adjust_x_factor: f32, //436 pub absolute_minimum_height: f32, //456, metres + /// physics_jump.HippyJumpMinHeight/MaxHeight. These are separate from + /// the ordinary jump-mode bounds in the stock data. + pub hippy_minimum_height: f32, + pub hippy_maximum_height: f32, } #[derive(Clone, Copy, Debug)] pub struct GroundJumpInput { pub flags_2468: u32, + pub flags_2480: u32, pub flags_2484: u32, pub flags_2488: u32, pub effective_forward: [f32; 4], //Processed160 diff --git a/crates/skate-core/src/air/trajectory/grind.rs b/crates/skate-core/src/air/trajectory/grind.rs index 747de9bd..2282cbff 100644 --- a/crates/skate-core/src/air/trajectory/grind.rs +++ b/crates/skate-core/src/air/trajectory/grind.rs @@ -1,343 +1,76 @@ -//! TU3 rail-trajectory candidate leaves82D60C80/82D60B98, -//! 82D6A398 and82D6A168. Admission and physical adjustment are separate owners. -use super::{math::*, Prediction, Trajectory}; +//! Original S3 trajectory grind acquisition and typed retained selection. +mod admission; +mod candidate; +use super::grind_surface::LandingOrientation; +use super::{Prediction, Trajectory, math::*}; use crate::{physics::grind_contact::Primitive, point_graph::PointGraph}; +pub use admission::{admitted_displacement, apply_admitted_target}; +pub use candidate::{descending_plane_time, consider_grind_primitive, take_best_grind, trajectory_box_filter}; #[derive(Clone, Copy, Debug)] pub struct GrindTrajectoryCandidate { - pub point: Vector, //0 projected point on rail - pub trajectory_point: Vector, //16 centre with radius/offset removed - pub direction: Vector, //32 normalized rail direction - pub approach: Vector, //48 velocity rejected from rail normal - pub distance: f32, //64 perpendicular miss distance - pub time: f32, //68 crossing time - pub angle: f32, //72 folded approach angle - pub frame: i32, //76 fixed60Hz time - pub primitive: usize, //80 query index + pub point: Vector, + pub trajectory_point: Vector, + pub direction: Vector, + pub approach: Vector, + pub distance: f32, + pub time: f32, + pub angle: f32, + pub frame: i32, + pub primitive: usize, } - -///The82C21930 result must come from the world geometry investigator. A -///spline's authored name or a nearest-distance guess cannot supply these lanes. #[derive(Clone, Copy, Debug)] pub struct GrindSurfaceEvidence { - pub kind: u32, //0 rail,1 edge,2 ledge,3 rejected - pub side: Vector, //classification64 + pub kind: u32, + pub side: Vector, } - #[derive(Clone, Debug)] pub struct GrindAssistLimits { - pub lock_distance: f32, //physics_mode80 - pub max_speed_squared_ledge: f32, //physics_trajectory520 - pub max_speed_squared_rail: f32, //524 - pub max_downward_speed: f32, //528 - pub ledge_scalars: [f32; 4], //532,536,540,544 - pub tip_scalar: f32, //512 - pub maximum_adjust_angle: f32, //560 degrees - ///Actual deck dimensions from82762E50 (DeckMidLength) and82C00868. + pub lock_distance: f32, + pub max_speed_squared_ledge: f32, + pub max_speed_squared_rail: f32, + pub max_downward_speed: f32, + pub ledge_scalars: [f32; 4], + pub tip_scalar: f32, + pub maximum_adjust_angle: f32, pub deck_dimensions: [f32; 2], } - -///Accepted-target publication82D6AEA4..82D6AF38. The original contact -///position/material stay intact; time, frame, trajectory and normal change. -pub fn apply_admitted_target( - prediction: &mut Prediction, - candidate: GrindTrajectoryCandidate, - correction: Vector, - support_normal: Vector, - reference_velocity: Vector, - maximum_adjust: f32, - landing_velocity_scalar: f32, - landing_max_angle: f32, -) { - super::launch::adjust_trajectory( - &mut prediction.request.trajectory, - candidate.frame, - correction, - maximum_adjust, - ); - prediction.result.contact_frame = candidate.frame; - prediction.result.contact_time = candidate.frame as f32 * STEP; - prediction.result.contact_normal = landing_normal( - candidate.direction, - reference_velocity, - support_normal, - landing_velocity_scalar, - landing_max_angle, - ); -} - -///82D6AF58: bias by cross-rail velocity, bound against the investigated -///support normal, reject the rail axis, then normalize. -pub fn landing_normal( - direction: Vector, - velocity: Vector, - support: Vector, - velocity_scalar: f32, - maximum_angle_degrees: f32, -) -> Vector { - let across = sub(velocity, scale(direction, dot(direction, velocity))); - let target = madd(across, -velocity_scalar, UP); - let limited = clamp_angle( - target, - support, - maximum_angle_degrees * f32::from_bits(0x3c8e_fa35), - ); - normalize(sub(limited, scale(direction, dot(limited, direction)))) -} - -///82BD3E78, shared with the offboard surface-frame port. -fn clamp_angle(target: Vector, reference: Vector, limit: f32) -> Vector { - let a = normalize(target); - let b = normalize(reference); - let axis = cross(a, b); - let angle = crate::trigonometry::acos(dot(a, b).clamp(-1.0, 1.0)); - let turns = angle.mul_add(f32::from_bits(0x3e22_f983), 0.5).floor(); - let wrapped = (-turns).mul_add(f32::from_bits(0x40c9_0fdb), angle); - if wrapped.abs() < limit || f32::from_bits(0x3780_0000) > dot(axis, axis) { - return target; - } - let unit = normalize(axis); - let (sin, cos) = crate::trigonometry::sin_cos(-limit * 0.5); - let q = scale(unit, sin); - scale( - madd( - cross(q, madd(reference, cos, cross(q, reference))), - 2.0, - reference, - ), - length(target), - ) -} - -///Admission and displacement portion82D6A840. The caller retries the next -///ranked candidate on None, then applies82D609E0 and82D6AF58 on success. -///This deliberately requires native surface evidence before any correction. -pub fn admitted_displacement( - prediction: Prediction, - candidate: GrindTrajectoryCandidate, - edge: Primitive, - reference_velocity: Vector, - processed_592: Vector, - surface: GrindSurfaceEvidence, - limits: &GrindAssistLimits, -) -> Option { - let natural_landing = prediction - .request - .trajectory - .position_at(prediction.result.contact_time); - if candidate.point[1] - natural_landing[1] < f32::from_bits(0xbecc_cccd) - || candidate.direction[1] > 0.9 - { - return None; - } - let direction = normalize(sub(edge.end, edge.start)); - let perpendicular = sub( - reference_velocity, - scale(direction, dot(direction, reference_velocity)), - ); - if (perpendicular[1] * (1.0 - direction[1].abs())).abs() > limits.max_downward_speed { - return None; - } - if surface.kind == 3 { - return None; - } - let mut horizontal = perpendicular; - horizontal[1] = 0.0; - let max_speed = if surface.kind == 2 { - limits.max_speed_squared_ledge - } else { - limits.max_speed_squared_rail - }; - if dot(horizontal, horizontal) > max_speed { - return None; - } - let delta = sub(candidate.trajectory_point, candidate.point); - let lock_distance = if surface.kind == 2 { - let incoming = dot(delta, surface.side) > 0.0; - let body = dot(sub(processed_592, edge.start), surface.side) > 0.0; - limits.lock_distance - * limits.ledge_scalars[if incoming { 0 } else { 2 } + if body { 0 } else { 1 }] - } else { - limits.lock_distance - }; - if candidate.distance >= lock_distance.max(0.1) { - return None; - } - let miss = sub(candidate.trajectory_point, edge.start); - let mut correction = scale( - sub( - miss, - scale(candidate.direction, dot(candidate.direction, miss)), - ), - -1.0, - ); - let distance = length(correction); - if distance >= limits.lock_distance { - let half_dimension = - limits.deck_dimensions[1].mul_add(0.5, limits.deck_dimensions[0] * 0.5); - correction = scale( - correction, - (-limits.tip_scalar).mul_add(half_dimension, distance) / distance, - ); - } - let original = prediction.request.trajectory.velocity; - let adjusted = madd(correction, reciprocal(candidate.time), original); - let angle = if dot(original, original) * dot(adjusted, adjusted) > f32::from_bits(0x38d1_b717) { - angle_between(original, adjusted) - } else { - 0.0 - }; - if angle > limits.maximum_adjust_angle * f32::from_bits(0x3c8e_fa35) { - return None; - } - Some(correction) -} - -///82D60C80's quadratic roots followed by82D60B98's later-root selection. -/// A double root must be strictly positive. The two-root branch takes max; -/// it does not add a positive-time restriction absent from the source. -pub fn descending_plane_time(t: Trajectory, point: Vector, normal: Vector) -> Option { - let c = dot(normal, t.position) - dot(point, normal); - let b = dot(normal, t.velocity); - let a = dot(normal, scale(t.acceleration, reciprocal(2.0))); - let discriminant = b * b - (4.0 * a) * c; - if discriminant < 0.0 { - return None; - } - let inverse = reciprocal(2.0 * a); - if discriminant > 0.0 { - let root = discriminant * inverse_length(discriminant); - Some((inverse * (-b + root)).max(inverse * (-b - root))) - } else { - let time = inverse * -b; - (time > 0.0).then_some(time) - } -} - -///82D6A398. The padding is physics_trajectory+516, not a host snap radius. -pub fn consider_grind_primitive( - prediction: Prediction, - edge: Primitive, - primitive: usize, - padding: f32, -) -> Option { - let mut delta = sub(edge.end, edge.start); - let normal_unscaled = cross(delta, cross(UP, delta)); - let normal = scale( - normal_unscaled, - inverse_length(dot(normal_unscaled, normal_unscaled)), - ); - let rail_length = length(delta); - //830BD320 is initialized to1 by82F825D0. The original endpoints remain - //unchanged; only the direction and endpoint overrun vector are capped. - if rail_length > 1.0 { - delta = scale(delta, reciprocal(rail_length)); - } - let offset = madd(normal, prediction.request.radius, scale(normal, padding)); - let time = descending_plane_time( - prediction.request.trajectory, - add(edge.start, offset), - normal, - )?; - let trajectory_point = sub(prediction.request.trajectory.position_at(time), offset); - let direction = normalize(delta); - let difference = sub(trajectory_point, edge.start); - let distance = length(cross(difference, direction)); - let point = madd(direction, dot(direction, difference), edge.start); - let extension = scale(delta, 0.1); - //Strict endpoint test82D6A708. No closest-point clamping. - if !(dot( - sub(point, sub(edge.start, extension)), - sub(point, add(edge.end, extension)), - ) < 0.0) - { - return None; - } - let velocity = prediction.request.trajectory.velocity; - let approach = normalize(sub(velocity, scale(normal, dot(normal, velocity)))); - let mut horizontal = sub(point, prediction.request.trajectory.position); - horizontal[1] = 0.0; - let angle = folded_approach_angle(horizontal, direction, normal); - Some(GrindTrajectoryCandidate { - point, - trajectory_point, - direction, - approach, - distance, - time, - angle, - frame: (time * 60.0) as i32, - primitive, - }) -} - -///8296EC98's oriented angle then82E09C80's actual fractional-turn fold. -///Keep the wrap arithmetic: acos(abs(dot)) is not binary32 equivalent. -fn folded_approach_angle(a: Vector, b: Vector, normal: Vector) -> f32 { - let mut angle = angle_between(a, b); - let aa = dot(a, a); - let bb = dot(b, b); - if aa > f32::from_bits(0x38d1_b717) && bb > f32::from_bits(0x38d1_b717) { - let unit = |v, square| { - let r = crate::physics::native_arithmetic::reciprocal_square_root_estimate(square); - scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.0), r)) - }; - if dot(cross(unit(a, aa), unit(b, bb)), normal) < 0.0 { - angle = f32::from_bits(0x40c9_0fdb) - angle; - } - } - let turns = angle * f32::from_bits(0x3e22_f983); - let fraction = turns - turns.floor(); - let wrapped = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb); - let sign = if wrapped > 0.0 { 1.0 } else { -1.0 }; - let magnitude = wrapped * sign; - let folded = if magnitude > f32::from_bits(0x3fc9_0fdb) { - magnitude - std::f32::consts::PI - } else { - magnitude - }; - (sign * folded).abs() -} - -///82D6A168 consumes one winner per call. Retrying after a rejected target -///must retain the remaining insertion order, not sort the list by distance. -pub fn take_best_grind( - candidates: &mut Vec, - difficulty_distance: f32, - height_penalty: &PointGraph<8>, -) -> Option { - let mut chosen = None; - let mut fallback = 1000.0; - let mut best_angle = std::f32::consts::PI; - let mut height = -10000.0; - for (i, candidate) in candidates.iter().enumerate() { - if candidate.distance < difficulty_distance { - let benefit = best_angle - candidate.angle; - if benefit > height_penalty.evaluate(candidate.point[1] - height) { - height = candidate.point[1]; - fallback = -1.0; - best_angle = candidate.angle; - chosen = Some(i); - } - } else if candidate.distance < fallback { - fallback = candidate.distance; - chosen = Some(i); - } - } - 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 provider_index: usize, + pub primitive_flags: u32, + pub orientation: LandingOrientation, pub point: Vector, - pub normal: Vector, + ///Selector2896, distinct from landing contact normal. + pub vertical_normal: Vector, +} +impl GrindTarget { + pub fn air_target(self) -> crate::physics::grind_air::Target { + crate::physics::grind_air::Target { + edge: self.edge, + primitive_flags: self.primitive_flags, + orientation: self.orientation, + } + } } #[derive(Clone, Copy, Debug)] pub struct GrindEvaluation { pub target: Option, pub score: f32, pub distance: f32, + ///9663/9664 can be written even when candidate admission fails. + pub effective_lock_distance: Option, + pub penalty_domain: f32, +} +impl GrindEvaluation { + pub fn penalty_input(self) -> f32 { + if let Some(distance) = self.effective_lock_distance { + let denominator = distance + f32::from_bits(0x3e19_999a); + if denominator < self.penalty_domain { + return (self.penalty_domain / denominator) * self.distance; + } + } + self.distance + } } diff --git a/crates/skate-core/src/air/trajectory/grind/admission.rs b/crates/skate-core/src/air/trajectory/grind/admission.rs new file mode 100644 index 00000000..7b2eceb3 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind/admission.rs @@ -0,0 +1,166 @@ +//! Original S3 82D6A840 admission and82D6AF58 landing publication. +use super::*; +///position/material stay intact; time, frame, trajectory and normal change. +pub fn apply_admitted_target( + prediction: &mut Prediction, + candidate: GrindTrajectoryCandidate, + correction: Vector, + support_normal: Vector, + reference_velocity: Vector, + maximum_adjust: f32, + landing_velocity_scalar: f32, + landing_max_angle: f32, +) { + super::super::launch::adjust_trajectory( + &mut prediction.request.trajectory, + candidate.frame, + correction, + maximum_adjust, + ); + prediction.result.contact_frame = candidate.frame; + prediction.result.contact_time = candidate.frame as f32 * STEP; + prediction.result.contact_normal = landing_normal( + candidate.direction, + reference_velocity, + support_normal, + landing_velocity_scalar, + landing_max_angle, + ); +} + +///82D6AF58: bias by cross-rail velocity, bound against the investigated +///support normal, reject the rail axis, then normalize. +pub fn landing_normal( + direction: Vector, + velocity: Vector, + support: Vector, + velocity_scalar: f32, + maximum_angle_degrees: f32, +) -> Vector { + let across = sub(velocity, scale(direction, dot(direction, velocity))); + let target = madd(across, -velocity_scalar, UP); + let limited = clamp_angle( + target, + support, + maximum_angle_degrees * f32::from_bits(0x3c8e_fa35), + ); + //82C1E170 removes only the positive normalized-direction component, + //multiplying by the ORIGINAL supplied direction. + let component = dot(limited, normalize(direction)); + normalize(if component > 0.0 { + sub(limited, scale(direction, component)) + } else { + limited + }) +} + +///82BD3E78, shared with the offboard surface-frame port. +fn clamp_angle(target: Vector, reference: Vector, limit: f32) -> Vector { + let a = normalize(target); + let b = normalize(reference); + let axis = cross(a, b); + let angle = crate::trigonometry::acos(dot(a, b).clamp(-1.0, 1.0)); + let turns = angle.mul_add(f32::from_bits(0x3e22_f983), 0.5).floor(); + let wrapped = (-turns).mul_add(f32::from_bits(0x40c9_0fdb), angle); + if wrapped.abs() < limit || f32::from_bits(0x3780_0000) > dot(axis, axis) { + return target; + } + let unit = normalize(axis); + let (sin, cos) = crate::trigonometry::sin_cos(-limit * 0.5); + let q = scale(unit, sin); + scale( + madd( + cross(q, madd(reference, cos, cross(q, reference))), + 2.0, + reference, + ), + length(target), + ) +} + +///Admission and displacement portion82D6A840. The caller retries the next +///ranked candidate on None, then applies82D609E0 and82D6AF58 on success. +///This deliberately requires native surface evidence before any correction. +pub fn admitted_displacement( + prediction: Prediction, + candidate: GrindTrajectoryCandidate, + edge: Primitive, + reference_velocity: Vector, + processed_592: Vector, + surface: GrindSurfaceEvidence, + limits: &GrindAssistLimits, + effective_lock_distance: &mut Option, +) -> Option { + let natural_landing = prediction + .request + .trajectory + .position_at(prediction.result.contact_time); + if candidate.point[1] - natural_landing[1] < f32::from_bits(0xbecc_cccd) + || candidate.direction[1] > 0.9 + { + return None; + } + let direction = normalize(sub(edge.end, edge.start)); + let perpendicular = sub( + reference_velocity, + scale(direction, dot(direction, reference_velocity)), + ); + if (perpendicular[1] * (1.0 - direction[1].abs())).abs() > limits.max_downward_speed { + return None; + } + if surface.kind == 3 { + return None; + } + let mut horizontal = perpendicular; + horizontal[1] = 0.0; + let max_speed = if surface.kind == 2 { + limits.max_speed_squared_ledge + } else { + limits.max_speed_squared_rail + }; + if dot(horizontal, horizontal) > max_speed { + return None; + } + let delta = sub(candidate.trajectory_point, candidate.point); + let lock_distance = if surface.kind == 2 { + let incoming = dot(delta, surface.side) > 0.0; + let body = dot(sub(processed_592, edge.start), surface.side) > 0.0; + limits.lock_distance + * limits.ledge_scalars[if incoming { 0 } else { 2 } + if body { 0 } else { 1 }] + } else { + limits.lock_distance + }; + let lock_distance = lock_distance.max(0.1); + *effective_lock_distance = Some(lock_distance); + if candidate.distance >= lock_distance { + return None; + } + let miss = sub(candidate.trajectory_point, edge.start); + let mut correction = scale( + sub( + miss, + scale(candidate.direction, dot(candidate.direction, miss)), + ), + -1.0, + ); + let distance = length(correction); + if distance >= limits.lock_distance { + let half_dimension = + limits.deck_dimensions[1].mul_add(0.5, limits.deck_dimensions[0] * 0.5); + correction = scale( + correction, + (-limits.tip_scalar).mul_add(half_dimension, distance) / distance, + ); + } + let original = prediction.request.trajectory.velocity; + let adjusted = madd(correction, reciprocal(candidate.time), original); + let angle = if dot(original, original) * dot(adjusted, adjusted) > f32::from_bits(0x38d1_b717) { + angle_between(original, adjusted) + } else { + 0.0 + }; + if angle > limits.maximum_adjust_angle * f32::from_bits(0x3c8e_fa35) { + return None; + } + Some(correction) +} diff --git a/crates/skate-core/src/air/trajectory/grind/candidate.rs b/crates/skate-core/src/air/trajectory/grind/candidate.rs new file mode 100644 index 00000000..e3dfab36 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind/candidate.rs @@ -0,0 +1,192 @@ +//! Original S3 82D60C80/82D60B98/82D6A398/82D6A168. +use super::*; + +///82C1F310 after provider traversal; pairwise rejection preserves global order. +pub fn trajectory_box_filter( + indices: &[usize], + primitives: &[Primitive], + takeoff: Vector, +) -> Vec { + let directions: Vec<_> = indices + .iter() + .map(|&i| { + let d = sub(primitives[i].end, primitives[i].start); + scale(d, inverse_length(dot(d, d))) + }) + .collect(); + let mut accepted = Vec::with_capacity(indices.len()); + for (i, &index) in indices.iter().enumerate() { + let a = primitives[index]; + let mut keep = true; + for (j, &other) in indices.iter().enumerate() { + if i == j { + continue; + } + let crossed = cross(directions[i], directions[j]); + if !(dot(crossed, crossed) < f32::from_bits(0x38d1_b717)) { + continue; + } + let b = primitives[other]; + let delta = sub(b.start, a.start); + let perpendicular = sub(delta, scale(directions[j], dot(directions[j], delta))); + let distance = dot(perpendicular, perpendicular); + if !(distance > f32::from_bits(0x38d1_b717) && distance < f32::from_bits(0x3c23_d70b)) { + continue; + } + let middle = scale(add(a.start, a.end), 0.5); + let from = sub(b.start, middle); + if !(dot(from, sub(b.end, middle)) < 0.) { + continue; + } + let up = normalize(cross(cross(directions[i], UP), directions[i])); + let height = dot(delta, up); + let alternative = add( + middle, + sub(from, scale(directions[j], dot(directions[j], from))), + ); + let first = sub(middle, takeoff); + let second = sub(alternative, takeoff); + if (height > -0.02 && dot(first, first) > dot(second, second)) || height > 0.02 { + keep = false; + } + } + if keep { + accepted.push(index); + } + } + accepted +} + +///82D60C80's quadratic roots followed by82D60B98's later-root selection. +/// A double root must be strictly positive. The two-root branch takes max; +/// it does not add a positive-time restriction absent from the source. +pub fn descending_plane_time(t: Trajectory, point: Vector, normal: Vector) -> Option { + let c = dot(normal, t.position) - dot(point, normal); + let b = dot(normal, t.velocity); + let a = dot(normal, scale(t.acceleration, reciprocal(2.0))); + let discriminant = b * b - (4.0 * a) * c; + if discriminant < 0.0 { + return None; + } + let inverse = reciprocal(2.0 * a); + if discriminant > 0.0 { + let root = discriminant * inverse_length(discriminant); + Some((inverse * (-b + root)).max(inverse * (-b - root))) + } else { + let time = inverse * -b; + (time > 0.0).then_some(time) + } +} + +///82D6A398. The padding is physics_trajectory+516, not a host snap radius. +pub fn consider_grind_primitive( + prediction: Prediction, + edge: Primitive, + primitive: usize, + padding: f32, +) -> Option { + let mut delta = sub(edge.end, edge.start); + let normal_unscaled = cross(delta, cross(UP, delta)); + let normal = scale( + normal_unscaled, + inverse_length(dot(normal_unscaled, normal_unscaled)), + ); + let rail_length = length(delta); + //830BD320 is initialized to1 by82F825D0. The original endpoints remain + //unchanged; only the direction and endpoint overrun vector are capped. + if rail_length > 1.0 { + delta = scale(delta, reciprocal(rail_length)); + } + let offset = madd(normal, prediction.request.radius, scale(normal, padding)); + let time = descending_plane_time( + prediction.request.trajectory, + add(edge.start, offset), + normal, + )?; + let trajectory_point = sub(prediction.request.trajectory.position_at(time), offset); + let direction = normalize(delta); + let difference = sub(trajectory_point, edge.start); + let distance = length(cross(difference, direction)); + let point = madd(direction, dot(direction, difference), edge.start); + let extension = scale(delta, 0.1); + //Strict endpoint test82D6A708. No closest-point clamping. + if !(dot( + sub(point, sub(edge.start, extension)), + sub(point, add(edge.end, extension)), + ) < 0.0) + { + return None; + } + let velocity = prediction.request.trajectory.velocity; + let approach = normalize(sub(velocity, scale(normal, dot(normal, velocity)))); + let mut horizontal = sub(point, prediction.request.trajectory.position); + horizontal[1] = 0.0; + let angle = folded_approach_angle(horizontal, direction, normal); + Some(GrindTrajectoryCandidate { + point, + trajectory_point, + direction, + approach, + distance, + time, + angle, + frame: (time * f32::from_bits(0x426f_ffff)) as i32, + primitive, + }) +} + +///8296EC98's oriented angle then82E09C80's actual fractional-turn fold. +///Keep the wrap arithmetic: acos(abs(dot)) is not binary32 equivalent. +fn folded_approach_angle(a: Vector, b: Vector, normal: Vector) -> f32 { + let mut angle = angle_between(a, b); + let aa = dot(a, a); + let bb = dot(b, b); + if aa > f32::from_bits(0x38d1_b717) && bb > f32::from_bits(0x38d1_b717) { + let unit = |v, square| { + let r = crate::physics::native_arithmetic::reciprocal_square_root_estimate(square); + scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.0), r)) + }; + if dot(cross(unit(a, aa), unit(b, bb)), normal) < 0.0 { + angle = f32::from_bits(0x40c9_0fdb) - angle; + } + } + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let wrapped = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb); + let sign = if wrapped > 0.0 { 1.0 } else { -1.0 }; + let magnitude = wrapped * sign; + let folded = if magnitude > f32::from_bits(0x3fc9_0fdb) { + magnitude - std::f32::consts::PI + } else { + magnitude + }; + (sign * folded).abs() +} + +///82D6A168 consumes one winner per call. Retrying after a rejected target +///must retain the remaining insertion order, not sort the list by distance. +pub fn take_best_grind( + candidates: &mut Vec, + difficulty_distance: f32, + height_penalty: &PointGraph<8>, +) -> Option { + let mut chosen = None; + let mut fallback = 1000.0; + let mut best_angle = std::f32::consts::PI; + let mut height = -10000.0; + for (i, candidate) in candidates.iter().enumerate() { + if candidate.distance < difficulty_distance { + let benefit = best_angle - candidate.angle; + if benefit > height_penalty.evaluate(candidate.point[1] - height) { + height = candidate.point[1]; + fallback = -1.0; + best_angle = candidate.angle; + chosen = Some(i); + } + } else if candidate.distance < fallback { + fallback = candidate.distance; + chosen = Some(i); + } + } + chosen.map(|i| candidates.remove(i)) +} diff --git a/crates/skate-core/src/air/trajectory/grind_surface.rs b/crates/skate-core/src/air/trajectory/grind_surface.rs index dc8af05d..f06ea1f1 100644 --- a/crates/skate-core/src/air/trajectory/grind_surface.rs +++ b/crates/skate-core/src/air/trajectory/grind_surface.rs @@ -1,168 +1,162 @@ -//! Geometry fields consumed by trajectory assist:82C20728,82C20C08, -//!82C21828 and82C21930. Material/foot-placement flags have other consumers. -use super::{grind::GrindSurfaceEvidence, math::*}; -use crate::math::Vector3; -use crate::physics::{ - board_world::BoardWorld, - ground_hang_geometry::{hang_lines, HangGeometryInput}, -}; +//! Static geometry investigation from original S3 TU3 82C20728/82C20C08. +//! Query execution is supplied by the world owner; a miss is not missing data. +//! No moving-object observations or authored material codes are synthesized. +use super::math::*; + +mod classify; +mod orientation; +mod landing; +pub use landing::{LandingOrientation, update_landing_orientation}; +#[cfg(test)] +mod tests; + +pub type V = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct InvestigationInput { + pub start: V, + pub end: V, + pub reference: V, + /// Input +48, enabled by input byte +64: a six-unit world-down probe. + pub optional_probe: Option, + /// physics_grinds layout +636, supplied from the stock collection. + pub deck_center_to_truck: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Probe { + pub start: V, + pub end: V, + pub radius: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ProbeHit { + pub fraction: f32, + pub position: V, + pub normal: V, + /// Original query result +100, NOT a rendering tag or triangle index. + pub packed_surface: u32, +} + +#[derive(Clone, Debug)] +pub struct Investigation { + pub center: V, + pub upmost_normal: V, + pub direction: V, + pub probes: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum GeometryType { + ThinRail = 0, + FatRail = 1, + Ledge = 2, + Impossible = 3, +} #[derive(Clone, Copy, Debug)] pub struct GrindSurface { - pub evidence: GrindSurfaceEvidence, - pub normal: Vector, - ///Investigation16/32: far-side query endpoints, consumed by82D883E0. - pub far_points: [Vector; 2], -} - -///The query consists of six consecutive48-byte descriptors; descriptor4 is -///the vertical centre line and descriptor5 the raised cross line. The optional -///seventh descriptor is a separate caller probe. Result indexing stays intact. -pub fn investigate( - world: &BoardWorld, - start: Vector, - end: Vector, - reference: Vector, - deck_center_to_truck: f32, -) -> Result, &'static str> { - let Some(lines) = hang_lines( - HangGeometryInput { - edge_start: xyz(start), - edge_end: xyz(end), - reference_point: xyz(reference), - }, - deck_center_to_truck, - ) else { - return Ok(None); - }; - let mut hits = [None; 6]; - for (i, line) in lines.iter().enumerate() { - hits[i] = world.query_swept_line(line.start, line.end, line.radius)?; - } - let direction = normalize(sub(end, start)); - let up = normalize(cross(direction, cross(UP, direction))); - let side = normalize(cross(UP, direction)); - //82C20ED8 reads result5 (stack448), not the centre probe atstack384. - let kind = if (hits[0].is_some() && hits[1].is_some()) || hits[5].is_some() { - 3 - } else if hits[0].is_some() { - if hits[2].is_some_and(|h| h.geometry.fraction < 0.65) { - 2 - } else { - 1 - } - } else if hits[1].is_some() { - if hits[3].is_some_and(|h| h.geometry.fraction < 0.65) { - 2 - } else { - 1 - } - } else { - 0 - }; - let side = if hits[0].is_none() && hits[1].is_some() { - scale(side, -1.0) - } else { - side - }; - let centre = madd(direction, dot(sub(reference, start), direction), start); - let far: [Vector; 2] = std::array::from_fn(|i| { - let p = hits[i + 2].map_or(lines[i + 2].end, |h| h.geometry.position); - [p.x, p.y, p.z, 0.0] - }); - //82C212CC..82C213F4: clearance angle16degrees expands both sides. - let clearance = f32::from_bits(0x3e8e_fa35); - let angle0 = crate::trigonometry::acos( - dot(scale(up, -1.0), normalize(sub(far[0], centre))).clamp(-1.0, 1.0), - ) + std::f32::consts::FRAC_PI_2 - + clearance; - let angle1 = std::f32::consts::TAU - - crate::trigonometry::acos( - dot(scale(up, -1.0), normalize(sub(far[1], centre))).clamp(-1.0, 1.0), - ) - - std::f32::consts::FRAC_PI_2 - - clearance; - let pi = std::f32::consts::PI; - let normal = if angle0 < angle1 && angle0 < pi && angle1 > pi { - up - } else { - let angle = if angle0 < angle1 { - if (pi - angle0).abs() < (pi - angle1).abs() { - angle0 - } else { - angle1 - } - } else { - (angle0 + angle1) * 0.5 - }; - rotate(direction, scale(up, -1.0), angle) - }; - Ok(Some(GrindSurface { - evidence: GrindSurfaceEvidence { kind, side }, - normal, - far_points: far, - })) -} - -///82C1E220 constructs a half-angle quaternion and rotates the supplied vector. -fn rotate(axis: Vector, value: Vector, angle: f32) -> Vector { - let (sin, cos) = crate::trigonometry::sin_cos(angle * 0.5); - let q = scale(axis, sin); - madd(cross(q, madd(value, cos, cross(q, value))), 2.0, value) -} -fn xyz(v: Vector) -> Vector3 { - Vector3::new(v[0], v[1], v[2]) + pub center: V, + pub far_points: [V; 2], + pub upmost_normal: V, + pub direction: V, + pub high_side: V, + pub normal_limits: [f32; 2], + pub kind: GeometryType, + pub audio_surface: u32, + pub physics_surface: u32, + /// Native output +116. Preserve unnamed bits, rather than invent meaning. + pub flags: u32, + /// 82C21828 evaluated from the geometric limits; distinct from +48 upmost. + pub tilted_upmost_normal: V, } 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), - ), - ] + pub const INVALID: u32 = 0x8000_0000; + pub const CURB: u32 = 0x4000_0000; + pub const BLOCKED_CROSS_SECTION: u32 = 0x2000_0000; + pub const STAIR: u32 = 0x1000_0000; + pub const OPTIONAL_NORMAL_TEST: u32 = 0x0800_0000; + pub const OPTIONAL_DROP_TEST: u32 = 0x0400_0000; + + /// 82C20C08's no-submission output; not an artificial startup blocker. + fn not_submitted(input: InvestigationInput) -> Self { + let distance = input.deck_center_to_truck; + let x = [1., 0., 0., 0.]; + let z = [0., 0., 1., 0.]; + Self { + center: input.reference, + far_points: [madd(x, distance, input.reference), sub(input.reference, scale(x, distance))], + upmost_normal: z, + direction: z, + high_side: z, + normal_limits: [0.; 2], + kind: GeometryType::Impossible, + audio_surface: 3, + physics_surface: 1, + flags: Self::INVALID, + tilted_upmost_normal: orientation::tilted_normal(z, z, [0.; 2]), } } } + +/// 82C20728: six fixed descriptors and the optional seventh descriptor. +/// Returns None exactly for the native inadequate-up-vector gate. +pub fn prepare(input: InvestigationInput) -> Option { + let delta = sub(input.end, input.start); + let raw_up = cross(delta, cross(UP, delta)); + let up_length = length(raw_up); + let retain_normalized = |v| { + let square = dot(v, v); + let inverse = inverse_length(square); + let magnitude = if square == 0. { 0. } else { square * inverse }; + if magnitude > f32::from_bits(0x3586_37bd) { scale(v, inverse) } else { v } + }; + let up = retain_normalized(raw_up); + let direction = retain_normalized(delta); + if up_length < f32::from_bits(0x3727_c5ac) { return None; } + let projected = madd(direction, dot(sub(input.reference, input.start), direction), input.start); + let center = sub(input.reference, sub(input.reference, projected)); + let side = cross(up, direction); + let short_up = scale(up, f32::from_bits(0x3d23_d70a)); + let near_side = scale(side, f32::from_bits(0x3db8_51ec)); + let far_side = scale(side, input.deck_center_to_truck); + let far_up = scale(up, input.deck_center_to_truck * f32::from_bits(0x3f87_ae14)); + let raised = scale(up, f32::from_bits(0x3db8_51ec)); + let line = |at, half, radius| Probe { start: add(at, half), end: sub(at, half), radius }; + let mut probes = vec![ + line(add(center, near_side), short_up, 0.), + line(sub(center, near_side), short_up, 0.), + line(add(center, far_side), far_up, 0.), + line(sub(center, far_side), far_up, 0.), + line(center, short_up, 0.), + line(add(center, raised), near_side, f32::from_bits(0x3a83_126f)), + ]; + if let Some(start) = input.optional_probe { + probes.push(Probe { start, end: add(start, [0., -6., 0., 0.]), radius: 0. }); + } + Some(Investigation { center, upmost_normal: up, direction, probes }) +} + +/// Full synchronous host equivalent of prepare/submit/wait/read/classify. +/// The callback must provide the original nearest-hit contract and packed +/// surface code for each probe, honoring its world-query filtering context. +pub fn investigate( + input: InvestigationInput, + mut query: impl FnMut(usize, Probe) -> Result, E>, +) -> Result { + let Some(plan) = prepare(input) else { return Ok(GrindSurface::not_submitted(input)); }; + let mut hits = [None; 7]; + for (index, &probe) in plan.probes.iter().enumerate() { + hits[index] = query(index, probe)?; + } + Ok(classify::resolve(&plan, &hits, 0)) +} + +/// Explicit result processing also permits a native-style reused output flag +/// word. Bit29 is set on obstruction, not cleared on the other native branch. +pub fn resolve(plan: &Investigation, hits: &[Option; 7], previous_flags: u32) -> GrindSurface { + classify::resolve(plan, hits, previous_flags) +} diff --git a/crates/skate-core/src/air/trajectory/grind_surface/classify.rs b/crates/skate-core/src/air/trajectory/grind_surface/classify.rs new file mode 100644 index 00000000..9a941180 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind_surface/classify.rs @@ -0,0 +1,85 @@ +use super::*; + +/// 82C20ED4..82C213F4, including the helpers 82C21410/21558/216A0. +pub(super) fn resolve(plan: &Investigation, hits: &[Option; 7], previous_flags: u32) -> GrindSurface { + let [h0, h1, h2, h3, h4, h5, supplied_h6] = *hits; + let h6 = if plan.probes.len() == 7 { supplied_h6 } else { None }; + let blocked = (h0.is_some() && h1.is_some()) || h5.is_some(); + let kind = if blocked { GeometryType::Impossible } + else if h0.is_some() { + if h2.is_some_and(|h| h.fraction < f32::from_bits(0x3f26_6666)) { GeometryType::Ledge } else { GeometryType::FatRail } + } else if h1.is_some() { + if h3.is_some_and(|h| h.fraction < f32::from_bits(0x3f26_6666)) { GeometryType::Ledge } else { GeometryType::FatRail } + } else { GeometryType::ThinRail }; + // Native result high side uses unguarded normalization, not NormalizeSafe. + let side = cross(UP, plan.direction); + let side = scale(side, inverse_length(dot(side, side))); + let high_side = if !blocked && h0.is_none() && h1.is_some() { scale(side, -1.) } else { side }; + let curb = kind == GeometryType::Ledge && is_curb(h2, h3, plan.upmost_normal, plan.center); + let stair = [h0, h1].into_iter().flatten().any(|h| h.packed_surface & 0xf80 == 0x400); + let optional_normal = h6.is_some_and(|h| optional_normal_test(h, plan.center, plan.direction)); + let optional_drop = h6.is_none_or(|h| !(h.position[1] - plan.center[1] > -1.)); + let mut flags = previous_flags & !(GrindSurface::INVALID | GrindSurface::CURB | GrindSurface::STAIR + | GrindSurface::OPTIONAL_NORMAL_TEST | GrindSurface::OPTIONAL_DROP_TEST); + if blocked { flags |= GrindSurface::BLOCKED_CROSS_SECTION; } + if curb { flags |= GrindSurface::CURB; } + if stair { flags |= GrindSurface::STAIR; } + if optional_normal { flags |= GrindSurface::OPTIONAL_NORMAL_TEST; } + if optional_drop { flags |= GrindSurface::OPTIONAL_DROP_TEST; } + let (audio_surface, physics_surface) = surface_info(kind, curb, [h0, h1, h2, h3, h4]); + let far_points = [h2.map_or(plan.probes[2].end, |h| h.position), h3.map_or(plan.probes[3].end, |h| h.position)]; + let negative_up = scale(plan.upmost_normal, -1.); + // Native first computes lengths, then separately refines their reciprocals. + // No safe-normalize epsilon is introduced for the far-point directions. + let angles = far_points.map(|point| { + let delta = sub(point, plan.center); + let unit = scale(delta, reciprocal(length(delta))); + crate::trigonometry::acos(dot(negative_up, unit).max(-1.).min(1.)) + }); + let half_pi = f32::from_bits(0x3fc9_0fdb); + let clearance = f32::from_bits(0x3e8e_fa35); + let normal_limits = [(angles[0] + half_pi) + clearance, + ((f32::from_bits(0x40c9_0fdb) - angles[1]) - half_pi) - clearance]; + let tilted_upmost_normal = orientation::tilted_normal(plan.direction, plan.upmost_normal, normal_limits); + GrindSurface { center: plan.center, far_points, upmost_normal: plan.upmost_normal, + direction: plan.direction, high_side, normal_limits, kind, audio_surface, + physics_surface, flags, tilted_upmost_normal } +} + +fn is_curb(a: Option, b: Option, up: V, center: V) -> bool { + let (Some(a), Some(b)) = (a, b) else { return false; }; + if !(dot(up, a.normal) > 0.99 && dot(up, b.normal) > 0.99) { return false; } + let ah = dot(sub(a.position, center), up); + let bh = dot(sub(b.position, center), up); + let (high, low) = if ah >= bh { (ah, bh) } else { (bh, ah) }; + high > -0.035 && high < 0.035 && low > -0.24 && low < -0.2 +} + +fn optional_normal_test(hit: ProbeHit, center: V, direction: V) -> bool { + // 82C1E170 subtracts ORIGINAL direction times a positive normalized dot. + let component = dot(hit.normal, normalize(direction)); + let projected = if component > 0. { sub(hit.normal, scale(direction, component)) } else { hit.normal }; + let normal = normalize(projected); + if !(f32::from_bits(0x3f66_6666) > normal[1]) { return false; } + let mut offset = sub(hit.position, center); + offset[1] = 0.; + dot(offset, normal) > 0. +} + +fn surface_info(kind: GeometryType, curb: bool, hits: [Option; 5]) -> (u32, u32) { + if curb { return (4, 2); } + if kind == GeometryType::ThinRail { + return (hits[4].map_or(11, |h| h.packed_surface & 0x7f), 4); + } + // Pair priority 0/1, then 2/3, then 4. Equal heights select the second. + for pair in [0, 2] { + let selected = match (hits[pair], hits[pair + 1]) { + (Some(a), Some(b)) => Some(if a.position[1] > b.position[1] { a } else { b }), + (a, b) => a.or(b), + }; + if let Some(hit) = selected { return unpack(hit.packed_surface); } + } + hits[4].map_or((3, 1), |h| unpack(h.packed_surface)) +} + +fn unpack(surface: u32) -> (u32, u32) { (surface & 0x7f, (surface >> 7) & 0x1f) } diff --git a/crates/skate-core/src/air/trajectory/grind_surface/landing.rs b/crates/skate-core/src/air/trajectory/grind_surface/landing.rs new file mode 100644 index 00000000..0b7c29b8 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind_surface/landing.rs @@ -0,0 +1,82 @@ +//! Original S3 TU3 82C21930 after six-query completion, not the S2 type3 gate. +//! Query ownership stays with the caller; None means actual failed completion. +use super::{GeometryType, GrindSurface, V, cross, dot, orientation, sub}; + +/// Native PossibleGrindOrientations output offsets. The returned up-vector is +/// separate: 82C21930 can leave it and these vectors untouched on failure. +#[derive(Clone, Copy, Debug)] +pub struct LandingOrientation { + pub kind: GeometryType, // +0 + pub garbage: bool, // +4 + pub boardslide_dir: V, // +16 + pub tipslide_dir: V, // +32 + pub backslash_dir: V, // +48 + pub high_side: V, // +64 +} + +/// 82C21930: primitive endpoints and grind_position form a six-probe geometry +/// investigation (optional_probe=None). Pass its completed surface as Some; +/// when prepare returns None, pass None and retain the caller's cached vectors. +/// takeoff_position is original r5, grind_position r6; neither is a velocity. +/// Completed kind3 is deliberately NOT rejected here; the analyzer owns its +/// later type3 rejection. Do not replace it with S2 82CFCA88's early type gate. +pub fn update_landing_orientation( + surface: Option<&GrindSurface>, + takeoff_position: V, + grind_position: V, + up: &mut V, + out: &mut LandingOrientation, +) { + let Some(surface) = surface else { + out.kind = GeometryType::Impossible; + out.garbage = true; + return; + }; + if surface.flags & GrindSurface::INVALID != 0 { + // 82C21A10..82C21A5C: completed but invalid geometry has its own + // fully written fallback, distinct from the no-completion branch. + let x = [1., 0., 0., 0.]; + *up = [0., 1., 0., 0.]; + out.kind = GeometryType::ThinRail; + out.garbage = true; + out.boardslide_dir = x; + out.tipslide_dir = x; + out.backslash_dir = negate(x); + out.high_side = x; + return; + } + + *up = orientation::tilted_normal(surface.direction, surface.upmost_normal, surface.normal_limits); + out.kind = surface.kind; + out.garbage = false; + out.high_side = surface.high_side; + // Original cross products are not followed by normalization. + let side = cross(surface.upmost_normal, surface.direction); + let tipslide_angle = f32::from_bits(0x3eb2_b8c3); // 822F8618 + if surface.kind == GeometryType::ThinRail { + out.boardslide_dir = side; + let oriented = if dot(side, sub(takeoff_position, grind_position)) > 0. { + side + } else { negate(side) }; + let axis = cross(oriented, surface.upmost_normal); + out.tipslide_dir = orientation::rotate(axis, oriented, tipslide_angle); + out.backslash_dir = orientation::rotate(axis, oriented, + f32::from_bits(0x401a_25c2)); // 822F93B8, stored pi-minus-backslash + } else { + let axis = cross(side, surface.upmost_normal); + out.boardslide_dir = if surface.kind == GeometryType::FatRail { + side + } else { cross(*up, surface.direction) }; + let sign = if dot(side, surface.high_side) > 0. { 1. } else { -1. }; + // Original sign vectors broadcast across all four lanes. These are + // multiplies, unlike the XOR sign inversion in the thin-rail branch. + out.backslash_dir = orientation::rotate( + axis.map(|v| v * sign), side.map(|v| v * sign), + f32::from_bits(0x3f3b_a866)); // 822F8614 + out.tipslide_dir = orientation::rotate( + axis.map(|v| v * -sign), side.map(|v| v * -sign), tipslide_angle); + } +} + +/// Native XOR toggles every sign bit, including W and signed zero. +fn negate(v: V) -> V { v.map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)) } diff --git a/crates/skate-core/src/air/trajectory/grind_surface/orientation.rs b/crates/skate-core/src/air/trajectory/grind_surface/orientation.rs new file mode 100644 index 00000000..0b1c9329 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind_surface/orientation.rs @@ -0,0 +1,20 @@ +use super::*; + +/// 82C21828. Strict straddling test and nearest-limit tie-to-high branch. +pub(super) fn tilted_normal(direction: V, up: V, limits: [f32; 2]) -> V { + let [lo, hi] = limits; + let pi = f32::from_bits(0x4049_0fdb); + let angle = if lo < hi { + if lo < pi && hi > pi { return up; } + if (pi - lo).abs() < (pi - hi).abs() { lo } else { hi } + } else { (hi + lo) * 0.5 }; + rotate(direction, scale(up, -1.), angle) +} + +/// 82C1E220: half-angle sine/cosine quaternion; existing independent PC +/// trigonometry supplies its numerical leaves, not a generated SIMD helper. +pub(super) fn rotate(axis: V, value: V, angle: f32) -> V { + let (sin, cos) = crate::trigonometry::sin_cos(angle * 0.5); + let q = scale(axis, sin); + madd(cross(q, madd(value, cos, cross(q, value))), 2., value) +} diff --git a/crates/skate-core/src/air/trajectory/grind_surface/tests.rs b/crates/skate-core/src/air/trajectory/grind_surface/tests.rs new file mode 100644 index 00000000..f216fc62 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/grind_surface/tests.rs @@ -0,0 +1,117 @@ +use super::*; + +fn input() -> InvestigationInput { + InvestigationInput { start: [0.; 4], end: [0., 0., 2., 0.], + reference: [4., 5., 1., 0.], optional_probe: None, deck_center_to_truck: 0.243 } +} +fn hit(y: f32, fraction: f32, surface: u32) -> ProbeHit { + ProbeHit { position: [0.243, y, 1., 0.], normal: UP, fraction, packed_surface: surface } +} + +#[test] +fn seven_descriptors_preserve_native_index_and_world_down_probe() { + let mut value = input(); + value.optional_probe = Some([2., 3., 4., 0.]); + let plan = prepare(value).unwrap(); + assert_eq!(plan.probes.len(), 7); + assert_eq!(plan.center, [0., 0., 1., 0.]); + assert_eq!(plan.probes[0].start, [0.09, 0.04, 1., 0.]); + assert_eq!(plan.probes[4].start, [0., 0.04, 1., 0.]); + assert_eq!(plan.probes[5].start, [0.09, 0.09, 1., 0.]); + assert_eq!(plan.probes[5].radius.to_bits(), 0x3a83_126f); + assert_eq!(plan.probes[6], Probe { start: [2., 3., 4., 0.], end: [2., -3., 4., 0.], radius: 0. }); + let mut called = Vec::new(); + investigate(value, |i, _| { called.push(i); Ok::<_, ()>(None) }).unwrap(); + assert_eq!(called, [0, 1, 2, 3, 4, 5, 6]); +} + +#[test] +fn raised_cross_probe_not_center_probe_blocks_geometry() { + let plan = prepare(input()).unwrap(); + let mut hits = [None; 7]; + hits[4] = Some(hit(0., 0.5, 17)); + let result = resolve(&plan, &hits, 0); + assert_eq!(result.kind, GeometryType::ThinRail); + assert_eq!((result.audio_surface, result.physics_surface), (17, 4)); + hits[5] = Some(hit(0., 0.5, 17)); + let result = resolve(&plan, &hits, 0); + assert_eq!(result.kind, GeometryType::Impossible); + assert_ne!(result.flags & GrindSurface::BLOCKED_CROSS_SECTION, 0); + assert_eq!(result.flags & GrindSurface::INVALID, 0); +} + +#[test] +fn strict_coarse_boundary_high_side_and_surface_codes() { + let plan = prepare(input()).unwrap(); + let mut hits = [None; 7]; + hits[1] = Some(hit(0., 0.5, 23 | (8 << 7))); + hits[3] = Some(hit(-0.1, 0.65, 9)); + let result = resolve(&plan, &hits, 0); + assert_eq!(result.kind, GeometryType::FatRail); + assert_eq!(result.high_side[0], -1.); + assert_eq!((result.audio_surface, result.physics_surface), (23, 8)); + assert_ne!(result.flags & GrindSurface::STAIR, 0); + hits[3].as_mut().unwrap().fraction = f32::from_bits(0.65f32.to_bits() - 1); + assert_eq!(resolve(&plan, &hits, 0).kind, GeometryType::Ledge); +} + +#[test] +fn curb_requires_two_coarse_normals_and_strict_height_ranges() { + let plan = prepare(input()).unwrap(); + let mut hits = [None; 7]; + hits[0] = Some(hit(0., 0.5, 17)); + hits[2] = Some(hit(0., 0.5, 17)); + hits[3] = Some(hit(-0.22, 0.9, 17)); + let result = resolve(&plan, &hits, 0); + assert_ne!(result.flags & GrindSurface::CURB, 0); + assert_eq!((result.audio_surface, result.physics_surface), (4, 2)); + hits[3].as_mut().unwrap().position[1] = -0.2; + assert_eq!(resolve(&plan, &hits, 0).flags & GrindSurface::CURB, 0); + hits[3].as_mut().unwrap().position[1] = -0.22; + hits[3].as_mut().unwrap().normal = [0., 0.99, 0., 0.]; + assert_eq!(resolve(&plan, &hits, 0).flags & GrindSurface::CURB, 0); +} + +#[test] +fn optional_query_flags_use_seventh_result_and_strict_drop_test() { + let mut value = input(); + value.optional_probe = Some([0., 2., 1., 0.]); + let plan = prepare(value).unwrap(); + let mut hits = [None; 7]; + hits[6] = Some(ProbeHit { position: [1., -1., 1., 0.], normal: [1., 0., 0., 0.], fraction: 0.5, packed_surface: 0 }); + let result = resolve(&plan, &hits, 0); + assert_ne!(result.flags & GrindSurface::OPTIONAL_NORMAL_TEST, 0); + assert_ne!(result.flags & GrindSurface::OPTIONAL_DROP_TEST, 0); + hits[6].as_mut().unwrap().position[1] = -0.99; + assert_eq!(resolve(&plan, &hits, 0).flags & GrindSurface::OPTIONAL_DROP_TEST, 0); +} + +#[test] +fn output_reuse_preserves_unassigned_bits_and_native_blocked_latch() { + let plan = prepare(input()).unwrap(); + let old = GrindSurface::INVALID | GrindSurface::CURB | GrindSurface::BLOCKED_CROSS_SECTION | 7; + let result = resolve(&plan, &[None; 7], old); + assert_eq!(result.flags & 7, 7); + assert_ne!(result.flags & GrindSurface::BLOCKED_CROSS_SECTION, 0); + assert_eq!(result.flags & (GrindSurface::INVALID | GrindSurface::CURB), 0); +} + +#[test] +fn vertical_query_uses_native_no_submission_result() { + let mut value = input(); + value.end = [0., 2., 0., 0.]; + let result = investigate(value, |_, _| -> Result, ()> { panic!("native gate should not submit") }).unwrap(); + assert_ne!(result.flags & GrindSurface::INVALID, 0); + assert_eq!((result.audio_surface, result.physics_surface), (3, 1)); + assert_eq!(result.kind, GeometryType::Impossible); + assert_eq!(result.tilted_upmost_normal[2], -1.); +} + +#[test] +fn tilted_normal_is_distinct_from_unrotated_upmost() { + let rail = [0., 0., 1., 0.]; + assert_eq!(orientation::tilted_normal(rail, UP, [2., 4.]), UP); + let tilted = orientation::tilted_normal(rail, UP, [0., 0.]); + assert_eq!(tilted[1], -1.); + assert_eq!(tilted[0], 0.); +} diff --git a/crates/skate-core/src/air/trajectory/mod.rs b/crates/skate-core/src/air/trajectory/mod.rs index 393722cd..2560c6b5 100644 --- a/crates/skate-core/src/air/trajectory/mod.rs +++ b/crates/skate-core/src/air/trajectory/mod.rs @@ -1,8 +1,8 @@ //! TU3 trajectory selection82D67848/82D682E8/82D68800. //! Host world storage and batch ownership are independent of console layout. mod launch; -pub mod grind; pub mod grind_surface; +pub mod grind; mod math; mod prediction; mod query; diff --git a/crates/skate-core/src/air/trajectory/scoring.rs b/crates/skate-core/src/air/trajectory/scoring.rs index 889d8907..4af6aa5d 100644 --- a/crates/skate-core/src/air/trajectory/scoring.rs +++ b/crates/skate-core/src/air/trajectory/scoring.rs @@ -1,6 +1,8 @@ -//! TU3 scoring82D68FE8/82D68AF0/82D69790 with world-supplied grind admission. +//! 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. use super::{ - launch::adjust_trajectory, math::*, Prediction, SelectorInput, SelectorSettings, SurfaceHit, + Prediction, SelectorInput, SelectorSettings, SurfaceHit, launch::adjust_trajectory, math::*, }; #[derive(Clone, Copy, Debug)] @@ -54,25 +56,20 @@ pub(super) fn score( let force_score = (force_dot * s.score_landing_force) * scalar; let direction_score = (sideways * s.score_landing_direction) * scalar; - //82D69C00 attempts acquisition only on the first-pass centre trajectory. - let mut evaluation = grind( + let 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 { + let mut 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) + s.grind_penalty_vs_distance.evaluate(evaluation.penalty_input()) } else { 0.0 }; @@ -109,6 +106,10 @@ pub(super) fn score( c.normal = c.prediction.result.contact_normal; let mut transition = 0.0; if adjusted_on_vert { + if input.directional_input < 0.5 && grind_score > 0.0 { + c.grind = None; + grind_score = s.grind_penalty_vs_distance.evaluate(0.0); + } let sign = if input.directional_input >= 0.5 { 1.0 } else { diff --git a/crates/skate-core/src/air/trajectory/selector.rs b/crates/skate-core/src/air/trajectory/selector.rs index 12ee78da..e1c50b95 100644 --- a/crates/skate-core/src/air/trajectory/selector.rs +++ b/crates/skate-core/src/air/trajectory/selector.rs @@ -1,10 +1,9 @@ //! Original selector Launch82D67848 and Update82D68800 with typed batch ownership. use super::{ - launch, + LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit, + Trajectory, launch, math::*, scoring::{self, Candidate}, - LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit, - Trajectory, }; ///The current authored world contains collision triangles and no grind edges. @@ -23,8 +22,8 @@ pub struct Selection { pub collision_position: Vector, //2688 pub com_trajectory: Trajectory, //2704 pub surface_category: u32, //9640 - pub wall_ride: bool, - pub grind: Option, //9661 + pub wall_ride: bool, //9661 + pub grind: Option, } #[derive(Clone, Debug, Default)] pub struct TrajectorySelector { @@ -196,28 +195,6 @@ impl TrajectorySelector { ///Complete a real world query batch in order. A second pass is exposed by ///pending()/requests(); host scheduling preserves its next Update boundary. pub fn complete_batch( - &mut self, - results: &[QueryResult], - input: SelectorInput, - 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, @@ -257,6 +234,9 @@ impl TrajectorySelector { } } let c = self.candidates[index]; + if let Some(target) = self.candidates.iter().take(count).find_map(|c| c.grind) { + self.grind_normal = Some(target.vertical_normal); + } self.selected_index = Some(index); //The copied COM trajectory is only recalculated on a valid-time result. let previous_com = self.selection.map(|v| v.com_trajectory); @@ -287,18 +267,11 @@ impl TrajectorySelector { } selection.surface_category = (c.prediction.result.surface >> 7) & 31; selection.com_trajectory = self.com_trajectory(selection, input, s); - //82D68C80: an accepted middle trajectory owns9653 and skips the second pass. + //82D68C80: only a winning centre acquisition locks the grind. 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 + let second_pass = self.pass == 1 && count >= 2 + && !self.grind_locked_to_middle && !(angle_between(selection.landing_normal, input.ground_normal) < s.minimum_normal_delta_second_pass); self.selection = Some(selection); diff --git a/crates/skate-core/src/animation/foot_ik/mod.rs b/crates/skate-core/src/animation/foot_ik/mod.rs index ec59d511..1ef465ec 100644 --- a/crates/skate-core/src/animation/foot_ik/mod.rs +++ b/crates/skate-core/src/animation/foot_ik/mod.rs @@ -3,17 +3,16 @@ pub mod blend; pub mod contact; pub mod drive; pub mod external; -mod math; mod physical_solve; pub mod post_contact; pub mod post_physics; +mod math; +pub use math::{interpolate_affine, inverse_affine}; pub use math::interpolate as interpolate_native; -pub use math::interpolate_affine; -pub use math::inverse_affine; pub mod settings; pub mod state; pub mod status; -#[cfg(test)] -mod tests; pub mod transforms; pub mod two_bone; +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/foot_ik/post_contact.rs b/crates/skate-core/src/animation/foot_ik/post_contact.rs index 525a143a..af122be0 100644 --- a/crates/skate-core/src/animation/foot_ik/post_contact.rs +++ b/crates/skate-core/src/animation/foot_ik/post_contact.rs @@ -67,7 +67,7 @@ pub(super) fn target( ///Scalar constructor path through original8252D980. Its argument is already ///clamped to [.01,1.5607964]; coefficients come from822FE3C0..E0. -pub(crate) fn tangent(angle: f32) -> f32 { +fn tangent(angle: f32) -> f32 { let quadrant = (angle * f32::from_bits(0x3F22_F983)).round_ties_even(); let reduced = (-quadrant).mul_add(f32::from_bits(0x3FC9_0FDB), angle); let reduced = (-quadrant).mul_add(f32::from_bits(0x2E85_A309), reduced); diff --git a/crates/skate-core/src/animation/grind_control.rs b/crates/skate-core/src/animation/grind_control.rs index 2475aa2e..48633d95 100644 --- a/crates/skate-core/src/animation/grind_control.rs +++ b/crates/skate-core/src/animation/grind_control.rs @@ -1,10 +1,13 @@ -//! TU3 GrindControlFade82BB0D40 and ControlGrindCrouch82BB10F8. -//! Animation attributes, physical observations and collection values remain -//! explicit inputs; these routines do not choose the grind or animation. +//! S3 TU3 GrindControlFade82BB0D40 and ControlGrindCrouch82BB10F8. +//! Adapted from the authorized ZIP after independent original-image comparison. +//! Animation selection, physical observations and tuning remain external inputs. +pub mod facing; +#[cfg(test)] +mod tests; #[derive(Clone, Copy, Debug)] pub struct FadeSettings { - ///Collection owner312: offsets1640,1644,1648,1652. + /// anim_motion/grind_twist: offsets1640/1644/1648/1652. pub response: f32, pub input_scale: f32, pub acceleration: f32, @@ -21,49 +24,88 @@ pub struct Fade { pub minimum: f32, pub maximum: f32, } - impl Fade { - ///Begin82BB0A30 obtains bounds by evaluating the animation attribute at - ///both endpoints. The caller supplies those evaluations and mirrored twist. + /// Begin82BB0A30 supplies the actual queried endpoints and mirrored twist. pub fn begin(&mut self, minimum: f32, maximum: f32, twist: f32) -> f32 { + let value = bound(twist, minimum, maximum); *self = Self { just_began: true, minimum, maximum, - value: bound(twist, minimum, maximum), - target: bound(twist, minimum, maximum), + value, + target: value, ..Self::default() }; - self.value + value } - - ///The first Update after Begin only clears byte8; it emits no attribute. + /// First Update only clears the latch. dt affects elapsed, NOT twist speed. pub fn update(&mut self, dt: f32, intent: f32, settings: FadeSettings) -> Option { if self.just_began { self.just_began = false; return None; } self.elapsed += dt; - self.target = bound(settings.input_scale.mul_add(intent, self.target), self.minimum, self.maximum); - let desired = (1.0 - settings.response).mul_add(self.value, self.target * settings.response); - let step = bound(desired - self.value, self.step - settings.acceleration, self.step + settings.acceleration); + self.target = bound( + settings.input_scale.mul_add(intent, self.target), + self.minimum, + self.maximum, + ); + let desired = + (1.0 - settings.response).mul_add(self.value, self.target * settings.response); + let step = bound( + desired - self.value, + self.step - settings.acceleration, + self.step + settings.acceleration, + ); self.step = bound(step, -settings.maximum_step, settings.maximum_step); self.value = bound(self.value + self.step, self.minimum, self.maximum); Some(self.value) } } -///Collection owner340: min1760,max1792,rate1816. Begin82BB1078 seeds -///previous with physical crouching output56+72; it does not seed from intent. -pub fn crouch(previous: f32, intent: f32, grind_crouch: f32, minimum: f32, maximum: f32, rate: f32, dt: f32) -> f32 { - let desired = bound(1.0 - intent, minimum, maximum) - .min(bound(1.0 - grind_crouch, minimum, maximum)); +/// S3 always includes physical crouch; S2's optional enable switch is absent. +/// anim_motion/grind_height: min1760/max1792/rate1816; previous starts at Anim+72. +pub fn crouch( + previous: f32, + intent: f32, + grind_crouch: f32, + minimum: f32, + maximum: f32, + rate: f32, + dt: f32, +) -> f32 { + let input = bound(1.0 - intent, minimum, maximum); + let physical = bound(1.0 - grind_crouch, minimum, maximum); + let desired = if input - physical >= 0.0 { + physical + } else { + input + }; let step = dt * rate; bound(desired, previous - step, previous + step) } +/// Begin82BB0CBC..CE4: mirror the Skeleton+504 angle exactly once. +pub fn mirrored_twist(twist: f32, mirrored: bool) -> f32 { + if !mirrored { + return twist; + } + if twist >= 0.0 { + std::f32::consts::PI - twist + } else { + -std::f32::consts::PI - twist + } +} + fn bound(value: f32, minimum: f32, maximum: f32) -> f32 { - //The native lower comparison precedes its upper comparison. - let value = if minimum - value >= 0.0 { minimum } else { value }; - if maximum - value >= 0.0 { value } else { maximum } + let value = if minimum - value >= 0.0 { + minimum + } else { + value + }; + if maximum - value >= 0.0 { + value + } else { + maximum + } } diff --git a/crates/skate-core/src/animation/grind_control/facing.rs b/crates/skate-core/src/animation/grind_control/facing.rs new file mode 100644 index 00000000..789bee15 --- /dev/null +++ b/crates/skate-core/src/animation/grind_control/facing.rs @@ -0,0 +1,15 @@ +//! CreateGrindAttributes82BAF3C8..444: original VMX reduction. +//! Let P(x,y,z,w)=(y,z,x,w). The instructions compute +//! P(n*P(b)-P(n)*b), then read lane1: n.z*b.x-n.x*b.z. +//! Raw vnmsubfp word1180636F has VA=0,VB=12,VC=13: VB-VA*VC. +//! No normalization, epsilon or grind-name classification occurs here. + +/// n is Motion+80 deck velocity, b is Basic+128 effective board forward. +/// ground_flag is the historical API name for Motion273 (processed bit20); +/// mirrored is LIVE ISkaterAnim virtual28. Ground80 is a different vector. +pub fn backwards(n: [f32; 3], b: [f32; 3], ground_flag: bool, mirrored: bool) -> bool { + let side = (-n[0]).mul_add(b[2], n[2] * b[0]); + // Native fcmpu/ble falls through on unordered as well as strictly positive. + let positive_or_unordered = !(side <= 0.0); + positive_or_unordered ^ ground_flag ^ mirrored +} diff --git a/crates/skate-core/src/animation/grind_control/tests.rs b/crates/skate-core/src/animation/grind_control/tests.rs new file mode 100644 index 00000000..9a7a4f93 --- /dev/null +++ b/crates/skate-core/src/animation/grind_control/tests.rs @@ -0,0 +1,82 @@ +use super::*; + +fn settings() -> FadeSettings { + FadeSettings { + response: 1.0, + input_scale: 1.0, + acceleration: 0.25, + maximum_step: 0.5, + } +} +#[test] +fn fade_skips_first_update_then_accelerates_and_reverses_without_dt_scaling() { + let mut a = Fade::default(); + assert_eq!(a.begin(-1.0, 1.0, 0.0), 0.0); + assert_eq!(a.update(10.0, 1.0, settings()), None); + assert_eq!(a.elapsed, 0.0); + let mut b = a; + assert_eq!(a.update(0.01, 1.0, settings()), Some(0.25)); + assert_eq!(b.update(0.5, 1.0, settings()), Some(0.25)); + assert_ne!(a.elapsed, b.elapsed); + assert_eq!(a.update(0.01, 1.0, settings()), Some(0.75)); + assert_eq!(a.update(0.01, -1.0, settings()), Some(1.0)); + assert_eq!(a.step, 0.25, "reversal must decelerate the retained step"); + assert_eq!(a.update(0.01, -1.0, settings()), Some(1.0)); + assert_eq!(a.update(0.01, -1.0, settings()), Some(0.75)); +} +#[test] +fn fade_target_accumulates_and_begin_discards_previous_run() { + let mut a = Fade::default(); + a.begin(-2.0, 2.0, 9.0); + assert_eq!((a.value, a.target), (2.0, 2.0)); + a.update(1.0, 0.0, settings()); + a.update(1.0, -0.25, settings()); + assert_eq!(a.target, 1.75); + a.update(1.0, -0.25, settings()); + assert_eq!(a.target, 1.5); + a.begin(-1.0, 1.0, -9.0); + assert_eq!( + (a.value, a.target, a.step, a.elapsed), + (-1.0, -1.0, 0.0, 0.0) + ); + assert!(a.just_began); +} +#[test] +fn crouch_obeys_physical_minimum_and_two_sided_rate_limit() { + assert_eq!(crouch(1.0, 0.0, 0.75, 0.25, 1.0, 2.0, 0.125), 0.75); + assert_eq!(crouch(0.75, 0.0, 0.75, 0.25, 1.0, 2.0, 0.125), 0.5); + assert_eq!(crouch(0.25, 0.0, 0.0, 0.25, 1.0, 2.0, 0.125), 0.5); + assert_eq!(crouch(1.0, 0.5, 0.0, 0.25, 1.0, 10.0, 1.0), 0.5); +} +#[test] +fn physical_twist_is_mirrored_exactly_once() { + assert_eq!(mirrored_twist(0.25, false), 0.25); + assert_eq!(mirrored_twist(0.25, true), std::f32::consts::PI - 0.25); + assert_eq!(mirrored_twist(-0.25, true), -std::f32::consts::PI + 0.25); +} +#[test] +fn facing_uses_cross_y_and_both_flags_including_zero_boundary() { + for ground in [false, true] { + for mirror in [false, true] { + assert_eq!( + facing::backwards([1.0, 8.0, 0.0], [0.0, 3.0, -1.0], ground, mirror), + true ^ ground ^ mirror + ); + assert_eq!( + facing::backwards([1.0, 8.0, 0.0], [0.0, 3.0, 1.0], ground, mirror), + ground ^ mirror + ); + assert_eq!( + facing::backwards([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], ground, mirror), + ground ^ mirror + ); + } + } + // The other cross-product term is not interchangeable with a board Z test. + assert!(facing::backwards( + [0.0, 0.0, 1.0], + [1.0, 0.0, 0.0], + false, + false + )); +} diff --git a/crates/skate-core/src/animation/mod.rs b/crates/skate-core/src/animation/mod.rs index 04d87d62..ac1b5f76 100644 --- a/crates/skate-core/src/animation/mod.rs +++ b/crates/skate-core/src/animation/mod.rs @@ -1,6 +1,4 @@ //! Animation timing and event semantics recovered from Skate 3 TU3. -pub mod bump; -pub mod manual; pub mod clip_clock; pub mod commands; pub mod output; @@ -30,7 +28,10 @@ pub mod ground_acceleration; pub mod foot_ik; pub mod vbr; pub mod kickturn; +pub mod manual; pub mod intent_filter; pub mod posture; + +pub mod bump; diff --git a/crates/skate-core/src/input/controller.rs b/crates/skate-core/src/input/controller.rs index 15cb448d..27e1b8ab 100644 --- a/crates/skate-core/src/input/controller.rs +++ b/crates/skate-core/src/input/controller.rs @@ -152,7 +152,7 @@ mod startup_tests; /// Independent reciprocal-square-root estimate followed by the original two /// refinement steps. Host arithmetic is not claimed to be bit-exact Xenon math. -pub fn magnitude(squared: f32) -> f32 { +pub(super) fn magnitude(squared: f32) -> f32 { let mut inverse = estimate(squared); for _ in 0..2 { let correction = (-squared).mul_add(inverse * inverse, 1.0); diff --git a/crates/skate-core/src/input/grind_intentions.rs b/crates/skate-core/src/input/grind_intentions.rs index 8b55b695..b4773042 100644 --- a/crates/skate-core/src/input/grind_intentions.rs +++ b/crates/skate-core/src/input/grind_intentions.rs @@ -1,21 +1,25 @@ -//! TU3 Listener::Fill825999F0, grind emissions8259ACF0..8259AD94. -//! Publish controller-space values; the stock graphs apply stance/mirror filters. +//! Original S3 Fill825999F0; emissions8259ACF0..8259AD94. +//! Derived controller-space values: graph filters own stance/mirror transforms. use super::{controller::DerivedControllerInput, riding_intentions::RidingIntent}; +#[cfg(test)] +mod tests; pub fn produce(controller: &DerivedControllerInput) -> Vec { let words = controller.words(); let left_x = f32::from_bits(words[7]); let right_x = f32::from_bits(words[9]); let right_y = f32::from_bits(words[10]); + // S3 adds both X axes. S2 Fill825FB4C8 uses right X alone: do not copy it. + let sum = left_x + right_x; + let lower = if -1.0 - sum >= 0.0 { -1.0 } else { sum }; + let translation = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; [ ("GrindBalanceX", -left_x), - ("PhysGrindTranslation", (left_x + right_x).clamp(-1.0, 1.0)), + ("PhysGrindTranslation", translation), ("PhysGrindStabilityNudge", left_x), ("PhysGrindUpDown", right_y), ] .into_iter() - // Native Fill omits neutral records. Rebuilding the AG map removes the - // previous tick's input, letting the graph end the corresponding MG intent. .filter(|(_, value)| *value != 0.0) .map(|(name, value)| RidingIntent { name, value }) .collect() diff --git a/crates/skate-core/src/input/grind_intentions/tests.rs b/crates/skate-core/src/input/grind_intentions/tests.rs new file mode 100644 index 00000000..065c6a7a --- /dev/null +++ b/crates/skate-core/src/input/grind_intentions/tests.rs @@ -0,0 +1,38 @@ +use super::*; + +fn values(x: f32, rx: f32, ry: f32) -> Vec<(&'static str, f32)> { + let mut words = [0; 26]; + words[7] = x.to_bits(); + words[9] = rx.to_bits(); + words[10] = ry.to_bits(); + produce(&DerivedControllerInput::from_words(words)) + .into_iter() + .map(|i| (i.name, i.value)) + .collect() +} +#[test] +fn all_four_s3_emissions_preserve_order_and_sign() { + assert_eq!( + values(0.25, 0.5, -0.75), + vec![ + ("GrindBalanceX", -0.25), + ("PhysGrindTranslation", 0.75), + ("PhysGrindStabilityNudge", 0.25), + ("PhysGrindUpDown", -0.75), + ] + ); +} +#[test] +fn translation_saturates_both_directions_and_cancellation_omits_only_it() { + assert_eq!(values(0.75, 0.75, 0.0)[1], ("PhysGrindTranslation", 1.0)); + assert_eq!(values(-0.75, -0.75, 0.0)[1], ("PhysGrindTranslation", -1.0)); + assert_eq!( + values(0.5, -0.5, 0.0), + vec![("GrindBalanceX", -0.5), ("PhysGrindStabilityNudge", 0.5),] + ); + assert!(values(0.0, -0.0, 0.0).is_empty()); +} +#[test] +fn left_only_translation_distinguishes_s3_from_s2() { + assert_eq!(values(0.5, 0.0, 0.0)[1], ("PhysGrindTranslation", 0.5)); +} diff --git a/crates/skate-core/src/input/mod.rs b/crates/skate-core/src/input/mod.rs index 68aac993..2b9e12d9 100644 --- a/crates/skate-core/src/input/mod.rs +++ b/crates/skate-core/src/input/mod.rs @@ -4,7 +4,6 @@ pub mod gesture; pub mod anticipation_intentions; pub mod animation_packet; pub mod controller; -pub mod manual_intentions; pub mod gameplay_map; pub mod tick; pub mod graph_intents; @@ -13,6 +12,7 @@ pub mod history; pub mod pad; pub mod power_sliding; pub mod riding_intentions; +pub mod manual_intentions; pub mod trick_intentions; pub mod set_turning; pub mod steering_intentions; diff --git a/crates/skate-core/src/input/offboard_intentions.rs b/crates/skate-core/src/input/offboard_intentions.rs index e5a1d140..3c696251 100644 --- a/crates/skate-core/src/input/offboard_intentions.rs +++ b/crates/skate-core/src/input/offboard_intentions.rs @@ -55,6 +55,8 @@ pub fn produce_discrete( emit("OB_AirBodyTweakY", right_y); //8259A604..A6C0 computes both;8259AF80 gates only NewToggle by actor bit10. + //The listener has no biped/category gate. Stock OffBoard AG maps these + //same toggle intentions to OB_Mount/OB_MountRaw; trigger edges recall. if !air_reckoning_active && !held(29) && held(22) { //Native bge does not take the branch for unordered timer values. if (rising(22) || !(axis(23) >= f32::from_bits(0x3cf5_c28f))) @@ -87,14 +89,6 @@ pub fn produce_analog( ) -> [OffboardIntent; 4] { let words = controller.words(); let stick = [f32::from_bits(words[7]), 0.0, f32::from_bits(words[8]), 0.0]; - produce_analog_world(stick, observation) -} - -/// Host camera conversion precedes the native world-space Biped attributes. -pub fn produce_analog_world( - stick: [f32; 4], - observation: AnalogObservation, -) -> [OffboardIntent; 4] { let mut horizontal = observation.effective_skeleton_z; horizontal[1] = 0.0; let forward = safe_unit(horizontal); @@ -143,4 +137,3 @@ fn safe_unit(vector: [f32; 4]) -> [f32; 4] { #[cfg(test)] #[path = "offboard_intentions/tests.rs"] mod tests; - diff --git a/crates/skate-core/src/input/offboard_intentions/tests.rs b/crates/skate-core/src/input/offboard_intentions/tests.rs index d95d1a20..6da4a66d 100644 --- a/crates/skate-core/src/input/offboard_intentions/tests.rs +++ b/crates/skate-core/src/input/offboard_intentions/tests.rs @@ -46,6 +46,16 @@ fn physical_and_modifier_gates_block_both_but_actor_gate_only_blocks_new() { } } +#[test] +fn y_remains_toggle_for_stock_offboard_mount_not_board_recall() { + //S3 listener8259A604..A6C0 has no physical-state input. The stock + //OffBoard/OBGround/Default graph consumes both of these for remount. + let output = values(toggle_words(false, 0.0), 0, false); + assert!(has(&output, "NewToggleOffBoardState")); + assert!(has(&output, "ToggleOffBoardState")); + assert!(!has(&output, "OB_RetrieveBoard")); +} + #[test] fn jump_requires_new_button_and_does_not_inherit_toggle_gates() { let mut words = [0; 26]; diff --git a/crates/skate-core/src/input/riding_intentions.rs b/crates/skate-core/src/input/riding_intentions.rs index 8773b242..fc938b75 100644 --- a/crates/skate-core/src/input/riding_intentions.rs +++ b/crates/skate-core/src/input/riding_intentions.rs @@ -1,4 +1,4 @@ -//! Push, brake, body-spin, steering, kick-turn, crouch and powerslide intents +//! Push, brake, body-spin, steering, kick-turn, crouch, powerslide and world-grab intents //! from the TU3 ActionGraph input listener (`825999F0`). use super::{ angle::left_stick_angle, @@ -151,6 +151,12 @@ pub fn produce( emit("RightSlide", (2.0 - angle) * SLIDE_SCALE); } } + //8259A54C/554 reads current raw flags bit28;8259AF68..7C emits + //GrabWorld (830BE780, named by initializer82F84EB0) while held. + //No actor inhibition or rising-edge gate applies to this intention. + if current & (1 << 28) != 0 { + emit("GrabWorld", 1.0); + } output } diff --git a/crates/skate-core/src/input/riding_intentions_tests.rs b/crates/skate-core/src/input/riding_intentions_tests.rs index ac57dd57..8abeb6b4 100644 --- a/crates/skate-core/src/input/riding_intentions_tests.rs +++ b/crates/skate-core/src/input/riding_intentions_tests.rs @@ -1,4 +1,22 @@ use super::*; +#[test] +fn right_bumper_publishes_held_grab_world_without_actor_gate() { + for (previous, current, expected) in [ + (0, 1 << 28, true), + (1 << 28, 1 << 28, true), + (1 << 28, 0, false), + (0, 1 << 29, false), + ] { + for actor_flags in [0, u32::MAX] { + let output = produce(&snapshot(previous, current, 0.5), actor_flags, + PushPreferences::default()); + let values: Vec<_> = output.iter().filter(|i| i.name == "GrabWorld").collect(); + assert_eq!(values.len(), usize::from(expected)); + if expected { assert_eq!(values[0].value, 1.0); } + } + } +} + #[test] fn crouch_preserves_zero_presence_and_uses_maximum_trigger() { let mut words = [0u32; 26]; diff --git a/crates/skate-core/src/physics/board_ground.rs b/crates/skate-core/src/physics/board_ground.rs index 48745f82..1fb5c353 100644 --- a/crates/skate-core/src/physics/board_ground.rs +++ b/crates/skate-core/src/physics/board_ground.rs @@ -120,7 +120,8 @@ pub struct BoardGroundState { pub wheel_contact_count: u8, ///Body7692, accumulated only while all four physical wheels lack contact. pub time_without_wheel_contact: f32, - /// Native wheel inertia+36 writes made after normal selection. + /// Angular drag: S3 postphysics82C08634 writes wheel inertia+36; + /// paired S2 postphysics82B372E0 names this mAngularDrag. pub wheel_angular_drag: [f32; 4], } impl Default for BoardGroundState { diff --git a/crates/skate-core/src/physics/board_runtime.rs b/crates/skate-core/src/physics/board_runtime.rs index 7590cec8..6eb6903d 100644 --- a/crates/skate-core/src/physics/board_runtime.rs +++ b/crates/skate-core/src/physics/board_runtime.rs @@ -55,6 +55,7 @@ impl BoardRuntime { self.step.diagnostic_capture = enabled; self.step.diagnostic_snapshot.take() } + /// `authored` contains part poses in board space. `spawn` places the deck /// part in world space, and moves the other parts by the same rigid delta. /// All mass properties come from the caller's physical data producer. @@ -102,13 +103,9 @@ impl BoardRuntime { } } - pub fn collision_group(&self) -> u32 { - self.collision_group - } + pub fn collision_group(&self) -> u32 { self.collision_group } ///All seven native board parts share the current assembly group. - pub fn set_collision_group(&mut self, group: u32) { - self.collision_group = group - } + pub fn set_collision_group(&mut self, group: u32) { self.collision_group = group } pub fn bodies(&self) -> &[BodySnapshot; BODY_COUNT] { &self.bodies @@ -151,8 +148,9 @@ impl BoardRuntime { body.rates.angular_velocity = Vector3::ZERO; body.rates.force_acceleration = gravity; body.rates.torque_acceleration = Vector3::ZERO; - body.rates.world_inverse_inertia = - world_inverse_inertia(body.rates.basis, body.inertia.inverse_tensor); + body.rates.world_inverse_inertia = world_inverse_inertia( + body.rates.basis, body.inertia.inverse_tensor, + ); } copy_pose(&hook, &mut self.hook.body.rates); self.forces.clear(); @@ -161,6 +159,32 @@ impl BoardRuntime { self.step = BoardStep::default(); } + /// Move the complete board assembly while preserving live rates, forces, + /// contacts, and solver ownership. This is the C055F0/C0B2C8 possession + /// position operation, distinct from the full physical reset above. + pub fn set_transform(&mut self, target: RetailAffineTransform) { + let authored = self.part_transforms(); + let mut parts = core::array::from_fn(|i| PartPose { + transform: pose_words(authored[i]), + local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), + body: Some(body_pose_words(self.bodies[i].rates)), + inertia: Some(inertia_words(self.bodies[i].inertia)), + }); + let mut hook = PartPose { + transform: pose_words(self.hook_transform()), + local_mass_frame: None, + body: Some(body_pose_words(self.hook.body.rates)), + inertia: None, + }; + set_board_transform(&mut parts, &mut hook, pose_words(target)); + for (body, part) in self.bodies.iter_mut().zip(&parts) { + copy_pose(part, &mut body.rates); + body.rates.world_inverse_inertia = + world_inverse_inertia(body.rates.basis, body.inertia.inverse_tensor); + } + copy_pose(&hook, &mut self.hook.body.rates); + } + /// Gameplay may publish actual velocities/accumulators or physical mode /// changes here. Rendering should use the immutable view or part poses. pub fn bodies_mut(&mut self) -> &mut [BodySnapshot; BODY_COUNT] { @@ -271,30 +295,6 @@ impl BoardRuntime { set_part_transform(&mut part, pose_words(requested)); copy_pose(&part, &mut self.hook.body.rates); } - ///82C0B2C8, used by board retrieval and hiding. Changes live part poses - ///without Reset's rate, contact-history, or force-queue clearing. - pub fn set_transform(&mut self, requested: RetailAffineTransform) { - let poses = self.part_transforms(); - let mut parts = core::array::from_fn(|i| PartPose { - transform: pose_words(poses[i]), - local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), - body: Some(body_pose_words(self.bodies[i].rates)), - inertia: Some(inertia_words(self.bodies[i].inertia)), - }); - let mut hook = PartPose { - transform: pose_words(self.hook_transform()), - local_mass_frame: None, - body: Some(body_pose_words(self.hook.body.rates)), - inertia: None, - }; - set_board_transform(&mut parts, &mut hook, pose_words(requested)); - for (body, part) in self.bodies.iter_mut().zip(&parts) { - copy_pose(part, &mut body.rates); - body.rates.world_inverse_inertia = - world_inverse_inertia(body.rates.basis, body.inertia.inverse_tensor); - } - copy_pose(&hook, &mut self.hook.body.rates); - } } const ZERO_INERTIA: RetailInertiaDynamics = RetailInertiaDynamics { diff --git a/crates/skate-core/src/physics/board_world.rs b/crates/skate-core/src/physics/board_world.rs index 13d60fe4..3040b95d 100644 --- a/crates/skate-core/src/physics/board_world.rs +++ b/crates/skate-core/src/physics/board_world.rs @@ -2,9 +2,11 @@ //! Rust owns geometry storage and traversal. Recovered query8277B720, //! triangle fixup82AD3130, material combine82763078 and contact retention //! determine the physical result, in world-triangle then moving-volume order. -pub mod query_metadata; +mod broadphase; mod query_index; +pub mod query_metadata; use crate::math::Vector3; +use broadphase::{conservative_bounds, primitive_bounds}; use query_metadata::{Bounds, QueryMetadata}; use super::{ @@ -106,26 +108,51 @@ pub struct BoardWorld { triangle_bounds: Vec, query_metadata: Option, query_index: query_index::QueryIndex, + maximum_fatness: f32, + maximum_triangle_margin: f32, contacts: Vec, buffer: ContactBuffer, - maximum_fatness: f32, } impl BoardWorld { pub fn new(triangles: Vec) -> Self { - let triangle_bounds = triangles.iter().map(|t| Bounds::from_points(t.triangle.vertices) - .unwrap_or(Bounds { min: Vector3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY), - max: Vector3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY) })).collect(); + let triangle_bounds: Vec<_> = triangles + .iter() + .map(|t| { + Bounds::from_points(t.triangle.vertices).unwrap_or(Bounds { + min: Vector3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY), + max: Vector3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY), + }) + }) + .collect(); let maximum_fatness = triangles .iter() - .map(|t| t.triangle.fatness) + .map(|t| { + if t.triangle.fatness.is_finite() && t.triangle.fatness >= 0. { + t.triangle.fatness + } else { + f32::INFINITY + } + }) + .fold(0., f32::max); + // The current thin-triangle leaf accepts barycentric weights down to + // -epsilon. Two negative weights can extend an axis by 2*epsilon*span. + let maximum_triangle_margin = triangle_bounds + .iter() + .map(|b| { + let span = (b.max.x - b.min.x) + .max(b.max.y - b.min.y) + .max(b.max.z - b.min.z); + span * (2. * broadphase::THIN_MARGIN) + }) .fold(0., f32::max); Self { - maximum_fatness, triangles, triangle_bounds, query_metadata: None, query_index: query_index::QueryIndex::default(), + maximum_fatness, + maximum_triangle_margin, contacts: Vec::new(), buffer: ContactBuffer { count: 0, @@ -162,44 +189,6 @@ impl BoardWorld { &self.triangles } - /// Conservative cluster culling, retaining canonical traversal order and - /// every triangle in intersecting clusters. Narrow-phase remains unchanged. - pub fn candidate_ranges(&self, bounds: Option) -> Vec> { - let (Some(metadata), Some(bounds)) = (&self.query_metadata, bounds) else { - return vec![0..self.triangles.len()]; - }; - let scale = [ - bounds.min.x, - bounds.min.y, - bounds.min.z, - bounds.max.x, - bounds.max.y, - bounds.max.z, - ] - .into_iter() - .map(f32::abs) - .fold(1., f32::max); - let bounds = bounds.expanded(self.maximum_fatness + scale * (8. * f32::EPSILON)); - self.query_index.query(bounds, &metadata.meshes) - .into_iter() - .map(|i| metadata.meshes[i].triangle_range.clone()) - .collect() - } - - pub fn line_candidates( - &self, - start: Vector3, - end: Vector3, - radius: f32, - ) -> impl Iterator { - let bounds = Bounds::from_points([start, end]).map(|b| conservative_bounds(b, radius + self.maximum_fatness)); - self.candidate_ranges(bounds) - .into_iter() - .flatten() - .filter(move |&i| self.query_metadata.is_none() || bounds.is_none_or(|b| self.triangle_bounds[i].overlaps(b))) - .map(|i| (i, &self.triangles[i])) - } - /// Board wheel segments use zero query radius; rounded world triangles /// still select the native swept branch through their own fatness. pub fn query_thin_line( @@ -280,7 +269,7 @@ impl BoardWorld { /// World-space shapes come from the live part poses and authored children. /// Cluster bounds include shape radii and the maximum predictive padding. - /// Candidate pairs retain source query order. + /// Candidate pairs retain world-triangle then moving-volume order. pub fn query_primitives( &mut self, volumes: &[BoardWorldVolume], @@ -295,10 +284,18 @@ impl BoardWorld { self.buffer.capacity = retention.capacity; self.buffer.distance_squared_threshold = retention.duplicate_distance_squared; self.buffer.deferred_reduction = u8::from(retention.deferred_reduction); - let padding = query.volume_padding.max(0.) + query.maximum_separating_distance.max(0.); + let padding = + if query.volume_padding.is_finite() && query.maximum_separating_distance.is_finite() { + query.volume_padding.max(0.) + query.maximum_separating_distance.max(0.) + } else { + f32::INFINITY + }; let volume_bounds: Vec<_> = volumes .iter() - .map(|v| primitive_bounds(v.primitive).map(|b| conservative_bounds(b, padding + self.maximum_fatness))) + .map(|v| { + primitive_bounds(v.primitive) + .map(|b| conservative_bounds(b, padding + self.maximum_fatness)) + }) .collect(); let bounds: Option> = volume_bounds.iter().copied().collect(); let bounds = bounds @@ -313,7 +310,8 @@ impl BoardWorld { let entry = &self.triangles[index]; for (volume, volume_bounds) in volumes.iter().zip(&volume_bounds) { if self.query_metadata.is_some() - && volume_bounds.is_some_and(|b| !self.triangle_bounds[index].overlaps(b)) { + && volume_bounds.is_some_and(|b| !self.triangle_bounds[index].overlaps(b)) + { continue; } let Some(manifold) = primitive_triangle_world_contacts( @@ -360,51 +358,6 @@ impl BoardWorld { } } -fn primitive_bounds(primitive: ContactPrimitive) -> Option { - let (center, radius) = match primitive { - ContactPrimitive::Sphere(s) => (s.center, s.radius), - ContactPrimitive::Capsule { - center, - axis, - half_length, - radius, - } => { - let offset = Vector3::new(axis.x * half_length, axis.y * half_length, axis.z * half_length); - return Bounds::from_points([ - Vector3::new(center.x - offset.x, center.y - offset.y, center.z - offset.z), - Vector3::new(center.x + offset.x, center.y + offset.y, center.z + offset.z), - ]).map(|b| b.expanded(radius.abs())); - }, - ContactPrimitive::RoundedBox { - center, - basis, - half_extents, - radius, - } => { - let half = [half_extents.x, half_extents.y, half_extents.z]; - let extent: [f32; 3] = std::array::from_fn(|axis| radius.abs() + basis.columns.iter().zip(half) - .map(|(column, h)| h.abs() * column[axis].abs()).sum::()); - return Bounds::from_points([ - Vector3::new(center.x - extent[0], center.y - extent[1], center.z - extent[2]), - Vector3::new(center.x + extent[0], center.y + extent[1], center.z + extent[2]), - ]); - } - ContactPrimitive::Triangle(t) => { - return Bounds::from_points(t.vertices).map(|b| b.expanded(t.fatness)); - } - }; - if !radius.is_finite() { - return None; - } - Bounds::from_points([center]).map(|b| b.expanded(radius.abs())) -} - -fn conservative_bounds(bounds: Bounds, padding: f32) -> Bounds { - let scale = [bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z] - .into_iter().map(f32::abs).fold(1., f32::max); - bounds.expanded(padding + scale * (8. * f32::EPSILON)) -} - /// Retention reads only contact header geometry, body IDs and material/tag. /// BoardStep builds the actual body workspaces after this frame's force queue /// has been applied, preventing stale acceleration copies in solver contacts. @@ -447,3 +400,7 @@ fn collision_from_record(record: &ContactRecord) -> BoardCollision { #[cfg(test)] #[path = "tests/board_world.rs"] mod tests; + +#[cfg(test)] +#[path = "board_world/tests.rs"] +mod broadphase_tests; diff --git a/crates/skate-core/src/physics/board_world/broadphase.rs b/crates/skate-core/src/physics/board_world/broadphase.rs new file mode 100644 index 00000000..6f83bec4 --- /dev/null +++ b/crates/skate-core/src/physics/board_world/broadphase.rs @@ -0,0 +1,182 @@ +//! ZIP world broadphase helpers; recovered narrow-phase stays in its existing modules. +use super::{BoardWorld, ContactPrimitive, Vector3, WorldTriangle, query_metadata::Bounds}; + +// Positive magnitude of thin_triangle's existing 0xb727_c5ac tolerance. +pub(super) const THIN_MARGIN: f32 = f32::from_bits(0x3727_c5ac); + +impl BoardWorld { + /// Conservative cluster culling, retaining canonical traversal order and + /// every triangle in intersecting clusters. Narrow-phase remains unchanged. + pub fn candidate_ranges(&self, bounds: Option) -> Vec> { + let (Some(metadata), Some(bounds)) = (&self.query_metadata, bounds.filter(|b| b.valid())) + else { + return vec![0..self.triangles.len()]; + }; + let scale = [ + bounds.min.x, + bounds.min.y, + bounds.min.z, + bounds.max.x, + bounds.max.y, + bounds.max.z, + ] + .into_iter() + .map(f32::abs) + .fold(1., f32::max); + let bounds = bounds.expanded( + self.maximum_fatness + self.maximum_triangle_margin + scale * (8. * f32::EPSILON), + ); + self.query_index + .query(bounds, &metadata.meshes) + .into_iter() + .map(|i| metadata.meshes[i].triangle_range.clone()) + .collect() + } + + pub fn line_candidates( + &self, + start: Vector3, + end: Vector3, + radius: f32, + ) -> impl Iterator { + let bounds = self.line_candidate_bounds(start, end, radius); + self.candidate_ranges(bounds) + .into_iter() + .flatten() + .filter(move |&i| { + self.query_metadata.is_none() + || bounds.is_none_or(|b| self.triangle_bounds[i].overlaps(b)) + }) + .map(|i| (i, &self.triangles[i])) + } + + /// Bounds shared with adapters that retain mesh pool/group/identity filtering. + /// Includes the current thin-leaf endpoint and barycentric margins. + pub fn line_candidate_bounds( + &self, + start: Vector3, + end: Vector3, + radius: f32, + ) -> Option { + if !radius.is_finite() || radius < 0. { + return None; + } + let span = (end.x - start.x) + .abs() + .max((end.y - start.y).abs()) + .max((end.z - start.z).abs()); + Bounds::from_points([start, end]).map(|b| { + conservative_bounds( + b, + radius + self.maximum_fatness + self.maximum_triangle_margin + span * THIN_MARGIN, + ) + }) + } + + /// ZIP hierarchy results in canonical mesh order, for current metadata consumers. + /// Pool, matching group, rejection mask and identity stay caller-owned. + pub fn candidate_mesh_indices( + &self, + bounds: Option, + ) -> Result, &'static str> { + let metadata = self.query_metadata()?; + let Some(bounds) = bounds.filter(|b| b.valid()) else { + return Ok((0..metadata.meshes.len()).collect()); + }; + Ok(self.query_index.query( + conservative_bounds(bounds, self.maximum_fatness + self.maximum_triangle_margin), + &metadata.meshes, + )) + } +} + +pub(super) fn primitive_bounds(primitive: ContactPrimitive) -> Option { + let (center, radius) = match primitive { + ContactPrimitive::Sphere(s) => (s.center, s.radius), + ContactPrimitive::Capsule { + center, + axis, + half_length, + radius, + } => { + if !radius.is_finite() { + return None; + } + let offset = Vector3::new( + axis.x * half_length, + axis.y * half_length, + axis.z * half_length, + ); + return Bounds::from_points([ + Vector3::new( + center.x - offset.x, + center.y - offset.y, + center.z - offset.z, + ), + Vector3::new( + center.x + offset.x, + center.y + offset.y, + center.z + offset.z, + ), + ]) + .map(|b| b.expanded(radius.abs())); + } + ContactPrimitive::RoundedBox { + center, + basis, + half_extents, + radius, + } => { + if !radius.is_finite() { + return None; + } + let half = [half_extents.x, half_extents.y, half_extents.z]; + let extent: [f32; 3] = std::array::from_fn(|axis| { + radius.abs() + + basis + .columns + .iter() + .zip(half) + .map(|(column, h)| h.abs() * column[axis].abs()) + .sum::() + }); + return Bounds::from_points([ + Vector3::new( + center.x - extent[0], + center.y - extent[1], + center.z - extent[2], + ), + Vector3::new( + center.x + extent[0], + center.y + extent[1], + center.z + extent[2], + ), + ]); + } + ContactPrimitive::Triangle(t) => { + if !t.fatness.is_finite() { + return None; + } + return Bounds::from_points(t.vertices).map(|b| b.expanded(t.fatness.abs())); + } + }; + if !radius.is_finite() { + return None; + } + Bounds::from_points([center]).map(|b| b.expanded(radius.abs())) +} + +pub(super) fn conservative_bounds(bounds: Bounds, padding: f32) -> Bounds { + let scale = [ + bounds.min.x, + bounds.min.y, + bounds.min.z, + bounds.max.x, + bounds.max.y, + bounds.max.z, + ] + .into_iter() + .map(f32::abs) + .fold(1., f32::max); + bounds.expanded(padding + scale * (8. * f32::EPSILON)) +} diff --git a/crates/skate-core/src/physics/board_world/query_index.rs b/crates/skate-core/src/physics/board_world/query_index.rs index b5c4b691..84141ea0 100644 --- a/crates/skate-core/src/physics/board_world/query_index.rs +++ b/crates/skate-core/src/physics/board_world/query_index.rs @@ -15,18 +15,37 @@ struct Node { } impl QueryIndex { pub(super) fn new(meshes: &[QueryMesh]) -> Self { - let mut index = Self { nodes: Vec::new(), order: (0..meshes.len()).collect() }; - if !meshes.is_empty() { index.build(meshes, 0..meshes.len()); } + let mut index = Self { + nodes: Vec::new(), + order: (0..meshes.len()).collect(), + }; + if !meshes.is_empty() { + index.build(meshes, 0..meshes.len()); + } index } fn build(&mut self, meshes: &[QueryMesh], range: Range) -> usize { - let bounds = Bounds::from_points(self.order[range.clone()].iter() - .flat_map(|&i| [meshes[i].local_bounds.min, meshes[i].local_bounds.max])).unwrap(); + let bounds = Bounds::from_points( + self.order[range.clone()] + .iter() + .flat_map(|&i| [meshes[i].local_bounds.min, meshes[i].local_bounds.max]), + ) + .unwrap(); let id = self.nodes.len(); - self.nodes.push(Node { bounds, children: None, range: range.clone() }); + self.nodes.push(Node { + bounds, + children: None, + range: range.clone(), + }); if range.len() > 8 { - let extents = [bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z]; - let axis = (0..3).max_by(|&a, &b| extents[a].total_cmp(&extents[b])).unwrap(); + let extents = [ + bounds.max.x - bounds.min.x, + bounds.max.y - bounds.min.y, + bounds.max.z - bounds.min.z, + ]; + let axis = (0..3) + .max_by(|&a, &b| extents[a].total_cmp(&extents[b])) + .unwrap(); let center = |i: usize| { let b = meshes[i].local_bounds; let low = [b.min.x, b.min.y, b.min.z][axis]; @@ -34,8 +53,9 @@ impl QueryIndex { low * 0.5 + high * 0.5 }; let mid = range.start + range.len() / 2; - self.order[range.clone()].select_nth_unstable_by(range.len() / 2, - |&a, &b| center(a).total_cmp(¢er(b)).then(a.cmp(&b))); + self.order[range.clone()].select_nth_unstable_by(range.len() / 2, |&a, &b| { + center(a).total_cmp(¢er(b)).then(a.cmp(&b)) + }); let left = self.build(meshes, range.start..mid); let right = self.build(meshes, mid..range.end); self.nodes[id].children = Some((left, right)); @@ -44,44 +64,28 @@ impl QueryIndex { } pub(super) fn query(&self, bounds: Bounds, meshes: &[QueryMesh]) -> Vec { let mut result = Vec::new(); - if self.nodes.is_empty() { return result; } + if self.nodes.is_empty() { + return result; + } let mut stack = vec![0]; while let Some(id) = stack.pop() { let node = &self.nodes[id]; - if !node.bounds.overlaps(bounds) { continue; } + if !node.bounds.overlaps(bounds) { + continue; + } if let Some((left, right)) = node.children { - stack.push(right); stack.push(left); + stack.push(right); + stack.push(left); } else { - result.extend(self.order[node.range.clone()].iter().copied() - .filter(|&i| meshes[i].local_bounds.overlaps(bounds))); + result.extend( + self.order[node.range.clone()] + .iter() + .copied() + .filter(|&i| meshes[i].local_bounds.overlaps(bounds)), + ); } } result.sort_unstable(); result } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{math::Vector3, physics::{drive_frames::RetailAffineTransform, board_world::query_metadata::QueryPool}}; - #[test] - fn hierarchy_matches_linear_scan_including_boundaries_and_source_order() { - let meshes: Vec<_> = (0..1024).map(|i| { - let x = ((i * 37) % 32) as f32 * 10.; - let z = (i / 32) as f32 * 10.; - QueryMesh { triangle_range: i..i+1, - local_to_world: RetailAffineTransform::IDENTITY, world_to_local: RetailAffineTransform::IDENTITY, - local_bounds: Bounds { min: Vector3::new(x, 0., z), max: Vector3::new(x+5., 5., z+5.) }, - matching_group: -1, pool: QueryPool::Ground } - }).collect(); - let index = QueryIndex::new(&meshes); - for x in [-100., 0., 5., 10., 47., 155., 310., 320.] { - for radius in [0., 5., 31., 1000.] { - let bounds = Bounds::from_points([Vector3::new(x, 0., x)]).unwrap().expanded(radius); - let expected: Vec<_> = meshes.iter().enumerate().filter(|(_, m)| m.local_bounds.overlaps(bounds)).map(|(i, _)| i).collect(); - assert_eq!(index.query(bounds, &meshes), expected); - } - } - } -} diff --git a/crates/skate-core/src/physics/board_world/query_metadata.rs b/crates/skate-core/src/physics/board_world/query_metadata.rs index af0f5c8e..e810ba59 100644 --- a/crates/skate-core/src/physics/board_world/query_metadata.rs +++ b/crates/skate-core/src/physics/board_world/query_metadata.rs @@ -74,7 +74,7 @@ impl Bounds { && p.z >= self.min.z && p.z <= self.max.z } - fn valid(self) -> bool { + pub(super) fn valid(self) -> bool { finite(self.min) && finite(self.max) && self.min.x <= self.max.x @@ -97,6 +97,11 @@ pub struct QueryMesh { pub world_to_local: RetailAffineTransform, pub local_bounds: Bounds, pub matching_group: i32, + /// S3 mesh184: trajectory rejection mask, separate from surface/material. + pub rejection_flags: u32, + /// S3 mesh176 support/body identity; static registration uses0. This is + /// separate from the host metadata index and authored mesh/unit names. + pub geometry: u32, pub pool: QueryPool, } diff --git a/crates/skate-core/src/physics/board_world/tests.rs b/crates/skate-core/src/physics/board_world/tests.rs new file mode 100644 index 00000000..2a9f2a22 --- /dev/null +++ b/crates/skate-core/src/physics/board_world/tests.rs @@ -0,0 +1,242 @@ +use super::*; +use crate::math::Basis3; +use crate::physics::drive_frames::RetailAffineTransform; +use query_metadata::{QueryMesh, QueryPool}; + +fn material() -> RetailContactMaterial { + RetailContactMaterial { + static_friction: 0.7, + dynamic_friction: 0.4, + restitution: 0.2, + } +} +fn face(x: f32, tag: u32, fatness: f32) -> WorldTriangle { + WorldTriangle::from_vertices( + [ + Vector3::new(x - 2., 0., -2.), + Vector3::new(x - 2., 0., 2.), + Vector3::new(x + 2., 0., -2.), + ], + material(), + tag, + 0xe0, + [1.; 3], + fatness, + ) + .unwrap() +} +fn annotated(triangles: Vec) -> BoardWorld { + let meshes = triangles + .iter() + .enumerate() + .map(|(i, t)| QueryMesh { + triangle_range: i..i + 1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(t.triangle.vertices).unwrap(), + matching_group: -1, + rejection_flags: 0x2000, + geometry: 5000 + i as u32, + pool: QueryPool::Ground, + }) + .collect(); + let packed_surfaces = vec![17; triangles.len()]; + BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces, + meshes, + static_edges: vec![], + island_flags: 0, + }, + ) + .unwrap() +} +fn tiled() -> Vec { + (0..1024) + .map(|i| face(((i * 37) % 1024) as f32 * 10., i, 0.)) + .collect() +} + +#[test] +fn zip_hierarchy_matches_inclusive_linear_mesh_scan_and_source_order() { + let world = annotated(tiled()); + let meshes = &world.query_metadata().unwrap().meshes; + let index = query_index::QueryIndex::new(meshes); + for x in [-100., -2., 0., 2., 8., 10., 155., 10230., 10300.] { + for radius in [0., 2., 31., 20000.] { + let bounds = Bounds::from_points([Vector3::new(x, 0., 0.)]) + .unwrap() + .expanded(radius); + let expected: Vec<_> = meshes + .iter() + .enumerate() + .filter(|(_, m)| m.local_bounds.overlaps(bounds)) + .map(|(i, _)| i) + .collect(); + assert_eq!(index.query(bounds, meshes), expected); + } + } +} + +#[test] +fn line_candidates_prune_distant_clusters_and_keep_canonical_indices() { + let world = annotated(tiled()); + let start = Vector3::new(-1., 1., -1.); + let end = Vector3::new(-1., -1., -1.); + let indices: Vec<_> = world + .line_candidates(start, end, 0.) + .map(|(i, _)| i) + .collect(); + assert_eq!(indices, vec![0]); + assert_eq!(world.query_metadata().unwrap().meshes[0].geometry, 5000); + assert_eq!( + world.query_metadata().unwrap().meshes[0].rejection_flags, + 0x2000 + ); + assert_eq!(world.query_metadata().unwrap().packed_surfaces[0], 17); + assert_eq!(world.candidate_ranges(None), vec![0..1024]); + let invalid = Bounds { + min: Vector3::new(f32::NAN, 0., 0.), + max: Vector3::ZERO, + }; + assert_eq!(world.candidate_ranges(Some(invalid)), vec![0..1024]); + let empty = annotated(vec![]); + assert_eq!(empty.line_candidates(start, end, 0.).count(), 0); +} + +#[test] +fn accelerated_lines_match_full_scan_with_fatness_and_equal_hits() { + let mut triangles = tiled(); + triangles.insert(0, face(0., 9000, 0.1)); + triangles.insert(0, face(0., 9001, 0.1)); + let linear = BoardWorld::new(triangles.clone()); + let world = annotated(triangles); + for x in [-100., -2.1, -1., 0., 2., 9., 509., 10229.] { + for radius in [0., 0.03, 0.5] { + let start = Vector3::new(x, 2., -1.); + let end = Vector3::new(x, -2., -1.); + assert_eq!( + world.query_swept_line(start, end, radius), + linear.query_swept_line(start, end, radius) + ); + } + } + assert_eq!( + world + .query_thin_line(Vector3::new(-1., 2., -1.), Vector3::new(-1., -2., -1.)) + .unwrap() + .unwrap() + .tag, + 9001 + ); +} + +#[test] +fn thin_endpoint_and_barycentric_tolerances_survive_broadphase() { + let triangles = vec![face(0., 7, 0.)]; + let linear = BoardWorld::new(triangles.clone()); + let world = annotated(triangles); + for (start, end) in [ + (Vector3::new(-1., 1., -1.), Vector3::new(-1., 0.000005, -1.)), + ( + Vector3::new(-1., -0.000005, -1.), + Vector3::new(-1., -1., -1.), + ), + ( + Vector3::new(-2.00002, 1., -1.), + Vector3::new(-2.00002, -1., -1.), + ), + ] { + let expected = linear.query_thin_line(start, end).unwrap(); + assert!( + expected.is_some(), + "fixture must exercise the thin-leaf tolerance" + ); + assert_eq!(world.query_thin_line(start, end).unwrap(), expected); + } +} + +#[test] +fn predictive_contacts_and_retention_match_full_scan_for_every_primitive() { + let mut triangles = tiled(); + triangles.insert(0, face(0., 9000, 0.02)); + triangles.insert(0, face(0., 9001, 0.02)); + let mut linear = BoardWorld::new(triangles.clone()); + let mut world = annotated(triangles); + let center = Vector3::new(-0.7, 0.25, -0.7); + let primitives = [ + ContactPrimitive::Sphere(Sphere { + center, + radius: 0.2, + }), + ContactPrimitive::Capsule { + center, + axis: Vector3::new(1., 0., 0.), + half_length: 0.3, + radius: 0.2, + }, + ContactPrimitive::RoundedBox { + center, + basis: Basis3 { + columns: [[0.6, 0., 0.8], [0., 1., 0.], [-0.8, 0., 0.6]], + }, + half_extents: Vector3::new(0.3, 0.2, 0.1), + radius: 0.02, + }, + ContactPrimitive::Triangle(triangle_from_volume( + [ + Vector3::new(-1., 0.1, -1.), + Vector3::new(-1., 0.1, 0.), + Vector3::new(0., 0.1, -1.), + ], + 0.03, + [1.; 3], + 0xe0, + )), + ]; + let mut observed = false; + for velocity in [-60., -1., 0., 1.] { + let volumes: Vec<_> = primitives + .iter() + .enumerate() + .map(|(i, &primitive)| BoardWorldVolume { + body: CollisionBody::Attached(i), + primitive, + linear_velocity: Vector3::new(0., velocity, 0.), + material: material(), + }) + .collect(); + for (padding, maximum) in [(0., 0.), (0., 0.5), (0.2, 0.), (0.02, 0.5)] { + let query = WorldContactSettings { + volume_padding: padding, + maximum_separating_distance: maximum, + edge_cos_bend_normal_threshold: -1., + convexity_epsilon: 0., + is_object: false, + }; + for capacity in [1, 3, 100] { + for deferred_reduction in [false, true] { + let retention = ContactRetentionSettings { + capacity, + duplicate_distance_squared: 0.000001, + deferred_reduction, + }; + let snapshot = |hits: &[BoardCollision]| { + hits.iter() + .map(|h| retention_record(h.body_a, h.contact)) + .collect::>() + }; + let expected = snapshot(linear.query_primitives(&volumes, query, retention)); + observed |= !expected.is_empty(); + assert_eq!( + snapshot(world.query_primitives(&volumes, query, retention)), + expected + ); + assert_eq!(world.dropped_contacts(), linear.dropped_contacts()); + } + } + } + } + assert!(observed); +} diff --git a/crates/skate-core/src/physics/grind_air.rs b/crates/skate-core/src/physics/grind_air.rs index 127a70ad..29fe7361 100644 --- a/crates/skate-core/src/physics/grind_air.rs +++ b/crates/skate-core/src/physics/grind_air.rs @@ -1,26 +1,41 @@ -//! 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]; +//! Original S3 82D71138/82D34FE8/82D712E0, with persistent native histories. +//! Independent PC arithmetic; not a bit-exact Xenon emulation claim. +mod angles; +mod pose; +mod prediction; +mod selection; +mod targets; +use super::skeleton_animation_record::AnimationPartTransform as Frame; +use super::{grind_contact::Primitive, native_arithmetic as arithmetic}; +use crate::air::trajectory::grind_surface::LandingOrientation; +pub use pose::PoseInput; +pub use targets::{DeckDimensions, Settings}; +pub type V = [f32; 4]; +const ZERO: V = [0.; 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, -} +const DEG: f32 = f32::from_bits(0x3c8e_fa35); +const RAD: f32 = f32::from_bits(0x4265_2ee1); +const PI: f32 = f32::from_bits(0x4049_0fdb); +const TAU: f32 = f32::from_bits(0x40c9_0fdb); + #[derive(Clone, Copy, Debug)] pub struct Target { pub edge: Primitive, - pub limits: [V; 3], + pub primitive_flags: u32, + pub orientation: LandingOrientation, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub active: bool, + pub board: Frame, + pub velocity: V, + pub angular_velocity: V, + pub up: V, + pub timestep: f32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + pub flags_2484: u32, } #[derive(Clone, Copy, Debug)] pub struct Adjustment { @@ -28,277 +43,98 @@ pub struct Adjustment { pub offset: V, pub angles: V, } -#[derive(Default)] +#[derive(Clone, Debug)] pub struct GrindAir { pub target: Option, offset_delta: V, offset: V, angle_delta: V, angles: V, - previous_kind: Option, + selected_kind: Option, + // Native array is initialized once, not cleared on each prediction. + headings: [f32; 12], +} +impl Default for GrindAir { + fn default() -> Self { + Self { + target: None, + offset_delta: ZERO, + offset: ZERO, + angle_delta: ZERO, + angles: ZERO, + selected_kind: None, + headings: [0.; 12], + } + } } impl GrindAir { - ///Start82D34FE8 clears retained displacement and angle integrators. + ///82D34FE8. Started/activated latches are owned by the existing KnownAir caller. pub fn start(&mut self, target: Target) { - *self = Self { - target: Some(target), - ..Self::default() - }; + self.target = Some(target); + self.offset_delta = ZERO; + self.offset = ZERO; + self.angle_delta = ZERO; + self.angles = ZERO; + self.selected_kind = None; } 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; + input: Input, + settings: &Settings, + ) -> Result, String> { + if !input.active { + return Ok(None); } + if input.flags_2480 & 0x0400_0000 != 0 + || input.flags_2472 & 0x8000 != 0 + || input.flags_2468 & 8 != 0 + { + self.offset = ZERO; + self.angles = ZERO; + return Ok(None); + } + let target = self + .target + .ok_or("Active GrindAirAdjust has no selected primitive")?; + settings.validate()?; 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 normal = unit(cross(rail, cross(input.up, rail))); + let samples = prediction::project(input, settings, rail, normal, &mut self.headings); + let contacts = + selection::contacts(&samples, settings.frames, target.edge.start, rail, normal); + let Some(kind) = selection::choose( + &contacts, + &self.headings, + settings, + input.flags_2484 & 0x0020_0000 != 0, + self.selected_kind, + ) else { + return Ok(None); }; - 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.selected_kind = Some(kind); + let probe = targets::CONTACTS[kind]; + let time = contacts.times[probe]; + //82D72610, per-frame and total displacement are separately bounded. self.offset_delta = limited( - add(self.offset_delta, scale(corrections[p], 1.0 / time)), - s.max_delta, + add( + self.offset_delta, + scale(contacts.corrections[probe], reciprocal(time)), + ), + settings.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 { + self.offset = limited(add(self.offset, self.offset_delta), settings.max_offset); + angles::update( + self, input, settings, target, &samples, rail, normal, kind, time, + ); + Ok(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 dot(a: V, b: V) -> f32 { + arithmetic::dot3(a, b) } fn sub(a: V, b: V) -> V { core::array::from_fn(|i| a[i] - b[i]) @@ -307,32 +143,77 @@ 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) + v.map(|x| x * s) +} +fn madd(v: V, s: f32, a: V) -> V { + core::array::from_fn(|i| v[i].mul_add(s, a[i])) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] +} +fn inverse_length(square: f32) -> f32 { + let mut r = arithmetic::reciprocal_square_root_estimate(square); + for _ in 0..2 { + r = (r * 0.5).mul_add((-square).mul_add(r * r, 1.), r); + } + r +} +fn reciprocal(value: f32) -> f32 { + let mut r = arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(value, 1.), r); + } + r } fn length(v: V) -> f32 { - dot(v, v).sqrt() + let s = dot(v, v); + if s == 0. { 0. } else { s * inverse_length(s) } } fn unit(v: V) -> V { - let l = length(v); - if l > 0.000001 { - scale(v, 1.0 / l) + let s = dot(v, v); + let r = inverse_length(s); + let magnitude = if s == 0. { 0. } else { s * r }; + if magnitude > f32::from_bits(0x3586_37bd) { + scale(v, r) } else { - [0.; 4] + ZERO } } -fn limited(v: V, max: f32) -> V { - let l = length(v); - if l > max { - scale(v, max / l) +fn limited(v: V, maximum: f32) -> V { + let magnitude = length(v); + if magnitude > maximum { + scale(v, maximum * reciprocal(magnitude)) } 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., - ] +fn wrap(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1. } else { 0. }) * TAU +} +///8296EC98: one refinement, positive full-turn signed angle then8258DB98 wrap. +fn signed_angle(a: V, b: V, axis: V) -> f32 { + let aa = dot(a, a); + let bb = dot(b, b); + if !(aa > f32::from_bits(0x38d1_b717) && bb > f32::from_bits(0x38d1_b717)) { + return 0.; + } + let normalize = |v, square| { + let r = arithmetic::reciprocal_square_root_estimate(square); + scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.), r)) + }; + let a = normalize(a, aa); + let b = normalize(b, bb); + let angle = crate::trigonometry::acos(dot(a, b).max(-1.).min(1.)); + wrap(if dot(cross(a, b), axis) < 0. { + TAU - angle + } else { + angle + }) } diff --git a/crates/skate-core/src/physics/grind_air/angles.rs b/crates/skate-core/src/physics/grind_air/angles.rs new file mode 100644 index 00000000..99253781 --- /dev/null +++ b/crates/skate-core/src/physics/grind_air/angles.rs @@ -0,0 +1,67 @@ +use super::*; +pub(super) fn update( + state: &mut GrindAir, + input: Input, + s: &Settings, + target: Target, + samples: &prediction::Samples, + rail: V, + normal: V, + kind: usize, + time: f32, +) { + //82D723D0 stores angleArray[type], NOT the range validator's frame index. + let heading = state.headings[kind]; + let desired = if heading > 0. { + targets::DESIRED[kind] + } else { + -targets::DESIRED[kind] + }; + let speed = ((desired - heading) / time * s.yaw_assist[kind]) + .max(-s.max_angle) + .min(s.max_angle); + state.angle_delta[1] = (state.angle_delta[1] * RAD + speed) + .max(-s.max_angle) + .min(s.max_angle) + * DEG; + let points = samples[(time + 0.5) as usize]; + let forward = sub(points[1], points[4]); + let projected = unit(sub(forward, scale(rail, dot(rail, forward)))); + let o = target.orientation; + let error = match kind { + 0 | 3 => { + let angle = signed_angle(projected, o.boardslide_dir, rail); + if angle > PI * 0.5 { + angle - PI + } else if angle < -PI * 0.5 { + angle + PI + } else { + angle + } + } + 1 | 2 => { + let from = if kind == 1 { + scale(projected, -1.) + } else { + projected + }; + let a = signed_angle(from, o.tipslide_dir, rail); + let b = signed_angle(from, o.backslash_dir, rail); + if a.abs() < b.abs() { a } else { b } + } + _ => 0., + }; + let maximum = s.max_angle * DEG; + state.angle_delta[0] = (state.angle_delta[0] + error / time) + .max(-maximum) + .min(maximum); + //82D71BB0 native broadcast subtraction is intentional, not vector rejection. + let projected_right = unit(input.board[0].map(|v| v - dot(input.board[0], normal))); + let correction = if dot(projected_right, projected_right) > 0.9 { + signed_angle(input.board[0], projected_right, input.board[2]) * 0.9 + } else { + 0. + }; + state.angle_delta[2] = if kind < 4 { correction / time } else { 0. }; + state.angles = add(state.angles, state.angle_delta); +} diff --git a/crates/skate-core/src/physics/grind_air/pose.rs b/crates/skate-core/src/physics/grind_air/pose.rs new file mode 100644 index 00000000..6ce92b7d --- /dev/null +++ b/crates/skate-core/src/physics/grind_air/pose.rs @@ -0,0 +1,76 @@ +use super::*; +#[derive(Clone, Copy, Debug)] +pub struct PoseInput { + ///Skeleton11920, not the processed physical board frame. + pub animation_to_world: Frame, + ///Skeleton11984, already produced by UpdateRootTransforms. + pub world_to_animation: Frame, + ///Skeleton12528: retained physical-board Z axis. + pub physical_forward: V, + ///Skeleton12672: this frame's mapped animation board translation. + pub animation_board_position: V, +} +///Full-angle axis matrix used by82D71430 and82BDCFB0 (not half-angle quaternion). +pub(super) fn axis_rotation(axis: V, angle: f32) -> Frame { + let (sin, cos) = crate::trigonometry::sin_cos(angle); + let [x, y, z, _] = axis; + let k = 1. - cos; + [ + [ + (k * x).mul_add(x, cos), + (k * x).mul_add(y, sin * z), + (k * x).mul_add(z, -sin * y), + 0., + ], + [ + (k * y).mul_add(x, -sin * z), + (k * y).mul_add(y, cos), + (k * y).mul_add(z, sin * x), + 0., + ], + [ + (k * z).mul_add(x, sin * y), + (k * z).mul_add(y, -sin * x), + (k * z).mul_add(z, cos), + 0., + ], + ZERO, + ] +} +pub(super) fn direction(frame: Frame, vector: V) -> V { + madd( + frame[2], + vector[2], + madd(frame[1], vector[1], scale(frame[0], vector[0])), + ) +} +impl Adjustment { + ///82BDCFB0: R(up,Y)*R(rail,X)*R(physicalZ,Z), converted to animation space, + ///then pivot around the current mapped board and add the local displacement. + pub fn local_transform(self, input: PoseInput) -> Frame { + //Unlike NormalizeSafe, this native wrapper has no zero-vector fallback. + let across = cross(UP, self.axis); + let across = scale(across, inverse_length(dot(across, across))); + let up = cross(self.axis, across); + let up = scale(up, inverse_length(dot(up, up))); + let yaw = axis_rotation(up, self.angles[1]); + let lean = axis_rotation(self.axis, self.angles[0]); + let pitch = axis_rotation(input.physical_forward, self.angles[2]); + let mut local = super::super::skeleton_animation_record::IDENTITY; + for i in 0..3 { + local[i] = direction( + input.world_to_animation, + direction( + yaw, + direction(lean, direction(pitch, input.animation_to_world[i])), + ), + ); + } + let pivot = input.animation_board_position; + local[3] = sub( + add(direction(input.world_to_animation, self.offset), pivot), + direction(local, pivot), + ); + local + } +} diff --git a/crates/skate-core/src/physics/grind_air/prediction.rs b/crates/skate-core/src/physics/grind_air/prediction.rs new file mode 100644 index 00000000..248ce278 --- /dev/null +++ b/crates/skate-core/src/physics/grind_air/prediction.rs @@ -0,0 +1,61 @@ +use super::*; +pub(super) type Samples = [[V; 5]; 12]; +///82D71430: frame zero is the actual processed deck; advance velocity before position. +pub(super) fn project( + input: Input, + s: &Settings, + rail: V, + normal: V, + headings: &mut [f32; 12], +) -> Samples { + let mut samples = [[ZERO; 5]; 12]; + let mut board = input.board; + let mut velocity = input.velocity; + let speed = length(input.angular_velocity); + let rotation = if speed < f32::from_bits(0x3780_0000) { + super::super::skeleton_animation_record::IDENTITY + } else { + pose::axis_rotation( + scale(input.angular_velocity, 1. / speed), + speed * input.timestep, + ) + }; + let acceleration_step = madd( + input.up, + s.stomp * input.timestep, + [0., input.timestep * f32::from_bits(0xc11c_cccd), 0., 0.], + ); + for frame in 0..s.frames { + samples[frame] = s.points.map(|p| { + madd( + board[2], + p[2], + madd(board[1], p[1], madd(board[0], p[0], board[3])), + ) + }); + //82D71F40, preserve the target's literal post-ACos 90/180 fold. + let forward = sub(samples[frame][1], samples[frame][4]); + let projected = unit(sub(forward, scale(normal, dot(normal, forward)))); + let aligned = if dot(projected, rail) < 0. { + scale(projected, -1.) + } else { + projected + }; + let mut angle = crate::trigonometry::acos(dot(aligned, rail).max(-1.).min(1.)).abs(); + if angle > 90. { + angle = 180. - angle; + } + let sign = if dot(normal, cross(aligned, rail)) < 0. { + -1. + } else { + 1. + }; + headings[frame] = -(sign * angle * RAD); + for axis in &mut board[..3] { + *axis = pose::direction(rotation, *axis); + } + velocity = add(velocity, acceleration_step); + board[3] = madd(velocity, input.timestep, board[3]); + } + samples +} diff --git a/crates/skate-core/src/physics/grind_air/selection.rs b/crates/skate-core/src/physics/grind_air/selection.rs new file mode 100644 index 00000000..83ca9459 --- /dev/null +++ b/crates/skate-core/src/physics/grind_air/selection.rs @@ -0,0 +1,82 @@ +use super::*; +pub(super) struct Contacts { + pub times: [f32; 5], + pub distances: [f32; 5], + pub corrections: [V; 5], + earliest: f32, +} +pub(super) fn contacts( + samples: &prediction::Samples, + count: usize, + origin: V, + rail: V, + normal: V, +) -> Contacts { + let mut out = Contacts { + times: [-1.; 5], + distances: [0.; 5], + corrections: [ZERO; 5], + earliest: count as f32, + }; + for p in 0..5 { + for i in 1..count { + let a = dot(normal, sub(samples[i - 1][p], origin)); + let b = dot(normal, sub(samples[i][p], origin)); + if a * b < 0. { + let f = (a / (a - b)).abs(); + let at = madd(samples[i - 1][p], 1. - f, scale(samples[i][p], f)); + let on = madd(rail, dot(sub(at, origin), rail), origin); + out.times[p] = (i - 1) as f32 + f; + out.corrections[p] = sub(on, at); + out.distances[p] = length(out.corrections[p]); + out.earliest = out.earliest.min(out.times[p]); + break; + } + } + } + out +} +pub(super) fn choose( + c: &Contacts, + headings: &[f32; 12], + s: &Settings, + inverted: bool, + previous: Option, +) -> Option { + let valid = |kind: usize| { + let p = targets::CONTACTS[kind]; + let t = c.times[p]; + if kind == 4 { + if c.times[2].min(c.times[3]) < 4. && (c.times[2] - c.times[3]).abs() > 2. { + return false; + } + } else if c.earliest < 5. && (c.earliest - t).abs() > 3. { + return false; + } + t > 0. + && c.distances[p] < s.distances[kind] + && (targets::DESIRED[kind] - headings[(t + 0.5) as usize].abs()).abs() < s.ranges[kind] + }; + let mut selected: Option = None; + for mut kind in 0..7 { + if inverted != (kind == 0) || !valid(kind) { + continue; + } + if kind == 3 { + if let Some(old @ (1 | 2)) = previous { + if valid(old) { + kind = old; + } + } + } + if selected.is_none_or(|old| { + c.distances[targets::CONTACTS[kind]] < c.distances[targets::CONTACTS[old]] + }) { + selected = Some(kind); + } + if selected.is_some_and(|kind| kind < 5) { + break; + } + } + selected +} diff --git a/crates/skate-core/src/physics/grind_air/targets.rs b/crates/skate-core/src/physics/grind_air/targets.rs new file mode 100644 index 00000000..dcfb46f4 --- /dev/null +++ b/crates/skate-core/src/physics/grind_air/targets.rs @@ -0,0 +1,48 @@ +use super::*; +pub(super) const CONTACTS: [usize; 7] = [0, 1, 4, 0, 2, 2, 3]; +pub(super) const DESIRED: [f32; 7] = [90., 90., 90., 90., 0., 0., 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 DeckDimensions { + pub middle_length: f32, + pub front_end_size: f32, + pub front_end_angle_degrees: f32, + pub truck_z_front: f32, + pub truck_y: f32, +} +impl DeckDimensions { + ///82D72B40 uses sine for tip height but adds the unprojected tip length to Z. + pub fn contact_points(self, tip_fraction: f32) -> [V; 5] { + let half = self.middle_length * 0.5; + let tip = self.front_end_size * tip_fraction; + let y = crate::trigonometry::sin_cos(self.front_end_angle_degrees * DEG).0 * tip; + let z = half + self.truck_z_front; + [ + ZERO, + [0., y, tip + half, 0.], + [0., self.truck_y, z, 0.], + [0., self.truck_y, -z, 0.], + [0., y, -(tip + half), 0.], + ] + } +} +impl Settings { + pub fn validate(&self) -> Result<(), String> { + // Host data validation, not a gameplay clamp or a fabricated setting. + if !(2..=12).contains(&self.frames) { + return Err("Stock GrindAir NumFramesToTest exceeds native 2..12 storage".into()); + } + Ok(()) + } +} diff --git a/crates/skate-core/src/physics/grind_contact.rs b/crates/skate-core/src/physics/grind_contact.rs index 5d751a97..eb94bf45 100644 --- a/crates/skate-core/src/physics/grind_contact.rs +++ b/crates/skate-core/src/physics/grind_contact.rs @@ -2,9 +2,22 @@ //! triangle query: exact parallel rejection and closed segment/barycentric bounds. use super::native_arithmetic::dot3; type V = [f32; 4]; +#[path = "grind_contact/families.rs"] pub mod families; +#[path = "grind_contact/entry.rs"] pub mod entry; +#[path = "grind_contact/control.rs"] pub mod control; +#[path = "grind_contact/admission.rs"] +pub mod admission; +#[path = "grind_contact/arithmetic.rs"] +mod arithmetic; +#[path = "grind_contact/investigator.rs"] +pub mod investigator; +#[path = "grind_contact/manager.rs"] +pub mod manager; +#[path = "grind_contact/balance.rs"] +pub mod balance; /// Native query entry: endpoints plus a spline owner, not three vectors. #[derive(Clone, Copy, Debug)] @@ -81,36 +94,39 @@ pub fn boardslide_candidate( flags_2476: u32, deck_center_to_truck: f32, truck_to_wheel: f32, + admission: &admission::Admission<'_>, ) -> Option { if flags_2476 & 0x4000_0000 != 0 { return None; } let delta = sub(edge.end, edge.start); - let direction = scale(delta, dot3(delta, delta).sqrt().recip()); + let direction = scale(delta, arithmetic::reciprocal(arithmetic::square_root(dot3(delta, delta)))); if dot3(upright_normal(direction), board[1]) <= 0.65 { return None; } + if !admission.test(400, direction, false).allowed { return None; } let depth = dot3(sub(board[3], contact.position), board[1]); - if depth * depth >= f32::from_bits(0x3b6b_edfa) { + let projected = scale(board[1], depth); + if dot3(projected, projected) >= f32::from_bits(0x3b6b_edfa) { return None; } if category == 100 && dot3(velocity, direction).abs() <= 0.75 && manager_frames <= 10 { return None; } let horizontal = sub(delta, scale(board[1], dot3(delta, board[1]))); - let length = dot3(horizontal, horizontal).sqrt(); + let length = arithmetic::square_root(dot3(horizontal, horizontal)); if !(length > 0.0) { return None; } - let horizontal = scale(horizontal, length.recip()); + let horizontal = scale(horizontal, arithmetic::reciprocal(length)); let alignment = dot3(horizontal, board[2]).abs(); let from = sub(contact.position, board[3]); let across = sub(from, scale(board[1], dot3(from, board[1]))); - let remaining = deck_center_to_truck - dot3(across, across).sqrt(); + let remaining = deck_center_to_truck - arithmetic::square_root(dot3(across, across)); let clearance = if alignment > 0.0 { - remaining / alignment * (1.0 - alignment * alignment).sqrt() + remaining / alignment * arithmetic::square_root(1.0 - alignment * alignment) } else { - 1000000.0 + 999.0 }; if !(clearance > truck_to_wheel) { return None; @@ -152,7 +168,7 @@ pub fn fifty_fifty_candidate_on_splines( if front.primitive != rear.primitive { let edge = primitives.get(front.primitive)?; let delta = sub(edge.end, edge.start); - let direction = scale(delta, dot3(delta, delta).sqrt().recip()); + let direction = scale(delta, arithmetic::inverse_square_root(dot3(delta, delta))); let across = cross(upright_normal(direction), direction); //820641A8. This comparison is signed in the original routine. if dot3(sub(front.position, rear.position), across) > 0.1 { @@ -165,13 +181,8 @@ pub fn fifty_fifty_candidate_on_splines( return None; } let difference = sub(front.position, rear.position); - let length = dot3(difference, difference).sqrt(); - // A degenerate pair cannot define a finite native grind frame. - if !length.is_finite() || length == 0.0 { - return None; - } Some(FiftyFiftyCandidate { - direction: scale(difference, length.recip()), + direction: scale(difference, arithmetic::inverse_square_root(dot3(difference, difference))), centre: scale(add(front.position, rear.position), 0.5), front: front.position, rear: rear.position, @@ -182,15 +193,15 @@ pub fn fifty_fifty_candidate_on_splines( ///82D370F8: upward-facing normal perpendicular to the spline direction. pub fn upright_normal(direction: V) -> V { let cross_up = cross([0.0, 1.0, 0.0, 0.0], direction); - let mut normal = cross(cross_up, direction); - let length = dot3(normal, normal).sqrt(); + let normal = cross(cross_up, direction); + let mut length = arithmetic::square_root(dot3(normal, normal)); if length <= 0.0 { return [1.0, 0.0, 0.0, 0.0]; } if normal[1] < 0.0 { - normal = scale(normal, -1.0); + length = -length; } - scale(normal, length.recip()) + scale(normal, arithmetic::reciprocal(length)) } ///82D88518, common active-grind angular admission. Plane projection occurs @@ -198,12 +209,12 @@ pub fn upright_normal(direction: V) -> V { pub fn within_approach_angle(direction: V, velocity: V, degrees: f32) -> bool { let normal = upright_normal(direction); let projected = sub(velocity, scale(normal, dot3(velocity, normal))); - let length = dot3(projected, projected).sqrt(); + let length = arithmetic::square_root(dot3(projected, projected)); if length <= 0.001 { return true; } - let alignment = dot3(scale(projected, length.recip()), direction).abs(); - alignment > (degrees * f32::from_bits(0x3c8e_fa35)).cos() + let alignment = dot3(scale(projected, arithmetic::reciprocal(length)), direction).abs(); + alignment > crate::trigonometry::cos(degrees * f32::from_bits(0x3c8e_fa35)) } /// Truck rectangles from82C1FDC0. The geometry dimensions come from the @@ -301,3 +312,7 @@ fn cross(a: V, b: V) -> V { 0.0, ] } + +#[cfg(test)] +#[path = "grind_contact/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/admission.rs b/crates/skate-core/src/physics/grind_contact/admission.rs new file mode 100644 index 00000000..02ad4cfa --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/admission.rs @@ -0,0 +1,107 @@ +//! S3 82D886B8, paired with S2 IsEntryAngleAcuteEnough 82DDA748. +//! Test each family/contact before arbitration, retaining its entry kind. +use super::{arithmetic, dot3, scale, sub, within_approach_angle, V}; +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum EntryKind { + RideFromAbove = 0, + RideFromBelow = 1, + RideIntoCoping = 2, + StayInGrind = 3, + ChangeGrind = 4, + AirToGrind = 5, + DropIn = 6, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Decision { + pub kind: EntryKind, + pub allowed: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct Admission<'a> { + pub category: u32, + /// The current processed state2508, not the requested/previous state. + pub state: u32, + pub speed: f32, + pub velocity: V, + /// physics_grinds/DVEntryThreshScalarVsSinSlope, X480/Y496. + pub threshold_vs_slope: &'a PointGraph<4>, +} + +impl Admission<'_> { + pub fn test(&self, requested_state: u32, tangent: V, allows_drop_in: bool) -> Decision { + let kind = if self.category == 400 || self.state == 701 { + if self.state == requested_state || requested_state == 402 && self.state == 404 { + EntryKind::StayInGrind + } else { + EntryKind::ChangeGrind + } + } else if self.category == 100 { + if allows_drop_in && self.speed < 1.45 { + EntryKind::DropIn + } else { + let slope = approach_slope_sine(tangent, self.velocity); + if slope > 0.96 { + EntryKind::RideIntoCoping + } else if slope > 0.25 { + EntryKind::RideFromBelow + } else { + EntryKind::RideFromAbove + } + } + } else { + EntryKind::AirToGrind + }; + let allowed = match kind { + EntryKind::RideFromAbove => within_approach_angle(tangent, self.velocity, 17.0), + EntryKind::StayInGrind => within_approach_angle(tangent, self.velocity, 90.0), + EntryKind::ChangeGrind => within_approach_angle(tangent, self.velocity, 24.0), + EntryKind::AirToGrind | EntryKind::DropIn => true, + EntryKind::RideFromBelow | EntryKind::RideIntoCoping => { + let square_speed = dot3(self.velocity, self.velocity); + let transverse = sub(scale(tangent, dot3(self.velocity, tangent)), self.velocity); + let square_transverse = dot3(transverse, transverse); + let ratio = if square_speed <= 0.002 { + 1.0 + } else { + arithmetic::square_root(square_transverse / square_speed) + }; + let slope = approach_slope_sine(tangent, self.velocity); + let multiplier = self.threshold_vs_slope.evaluate(slope); + let limit = (1.0 - ratio).mul_add(2.4, 2.8) * multiplier; + square_transverse < limit * limit + } + }; + Decision { kind, allowed } + } +} + +/// S3 82D861D8 / S2 CalcApproachSlopeSine 82DDA1A8. +/// This is the vertical component of normalized cross-rail velocity, not +/// rail incline or the board's forward/rail angle. +pub fn approach_slope_sine(tangent: V, velocity: V) -> f32 { + slope_sine(tangent, velocity, 0.01) +} + +/// S3 82D860F8 uses a smaller dead zone for coping and engagement. +pub fn engagement_slope_sine(tangent: V, velocity: V) -> f32 { + slope_sine(tangent, velocity, 0.001) +} + +fn slope_sine(tangent: V, velocity: V, dead_zone: f32) -> f32 { + let transverse = sub(velocity, scale(tangent, dot3(velocity, tangent))); + let length = arithmetic::square_root(dot3(transverse, transverse)); + if length <= dead_zone { + 0.0 + } else { + arithmetic::reciprocal(length) * transverse[1] + } +} + +#[cfg(test)] +#[path = "admission_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/admission_tests.rs b/crates/skate-core/src/physics/grind_contact/admission_tests.rs new file mode 100644 index 00000000..37485853 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/admission_tests.rs @@ -0,0 +1,50 @@ +use super::*; + +const GRAPH: PointGraph<4> = PointGraph { x: [0.0, 0.3, 0.7, 1.0], y: [1.0; 4] }; +const RAIL: V = [1.0, 0.0, 0.0, 0.0]; +fn context(category: u32, state: u32, velocity: V) -> Admission<'static> { + Admission { category, state, speed: 2.0, velocity, threshold_vs_slope: &GRAPH } +} + +#[test] +fn current_grind_and_family_change_have_different_angle_gates() { + let input = context(400, 401, [0.8660254, 0.0, 0.5, 0.0]); + assert_eq!(input.test(401, RAIL, false), Decision { kind: EntryKind::StayInGrind, allowed: true }); + assert_eq!(input.test(403, RAIL, true), Decision { kind: EntryKind::ChangeGrind, allowed: false }); + let backslash = Admission { state: 404, ..input }; + assert_eq!(backslash.test(402, RAIL, true).kind, EntryKind::StayInGrind); +} + +#[test] +fn ground_entries_distinguish_above_below_coping_and_drop_in() { + let above = context(100, 100, [2.0, -1.0, 0.0, 0.0]); + assert_eq!(above.test(401, RAIL, false).kind, EntryKind::RideFromAbove); + let below = context(100, 100, [2.0, 1.0, 1.0, 0.0]); + assert_eq!(below.test(401, RAIL, false).kind, EntryKind::RideFromBelow); + let coping = context(100, 100, [2.0, 1.0, 0.0, 0.0]); + assert_eq!(coping.test(401, RAIL, false).kind, EntryKind::RideIntoCoping); + let drop_in = Admission { speed: 1.0, ..above }; + assert_eq!(drop_in.test(403, RAIL, true), Decision { kind: EntryKind::DropIn, allowed: true }); + assert_eq!(drop_in.test(401, RAIL, false).kind, EntryKind::RideFromAbove); + let boundary = Admission { speed: 1.45, ..drop_in }; + assert_ne!(boundary.test(403, RAIL, true).kind, EntryKind::DropIn); +} + +#[test] +fn coping_admission_uses_cross_rail_speed_and_the_authored_graph() { + let slow = context(100, 100, [1.0, 1.0, 0.0, 0.0]); + assert!(slow.test(401, RAIL, false).allowed); + let fast = context(100, 100, [1.0, 10.0, 0.0, 0.0]); + assert!(!fast.test(401, RAIL, false).allowed); + let zero = PointGraph { x: GRAPH.x, y: [0.0; 4] }; + assert!(!Admission { threshold_vs_slope: &zero, ..slow }.test(401, RAIL, false).allowed); +} + +#[test] +fn airborne_admission_and_projection_keep_the_original_leaf_boundary() { + assert_eq!(context(200, 201, [0.0, 0.0, 8.0, 0.0]).test(401, RAIL, false), + Decision { kind: EntryKind::AirToGrind, allowed: true }); + assert!(context(100, 100, [0.0, -8.0, 0.0, 0.0]).test(401, RAIL, false).allowed); + assert_eq!(approach_slope_sine(RAIL, [3.0, 0.0, 0.0, 0.0]), 0.0); + assert_eq!(approach_slope_sine(RAIL, [3.0, 0.01, 0.0, 0.0]), 0.0); +} diff --git a/crates/skate-core/src/physics/grind_contact/arithmetic.rs b/crates/skate-core/src/physics/grind_contact/arithmetic.rs new file mode 100644 index 00000000..94e84573 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/arithmetic.rs @@ -0,0 +1,29 @@ +//! Refinement sequence used by S3 82D861D8/82D88518/82D886B8. +//! Seeds use existing independent PC math, not a Xenon bit-exact claim. +use crate::physics::native_arithmetic; + +pub(super) fn reciprocal(value: f32) -> f32 { + let mut result = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + let error = (-result).mul_add(value, 1.0); + result = result.mul_add(error, result); + } + result +} + +pub(super) fn inverse_square_root(value: f32) -> f32 { + let mut result = native_arithmetic::reciprocal_square_root_estimate(value); + for _ in 0..2 { + let square = result * result; + let half = result * 0.5; + let error = (-value).mul_add(square, 1.0); + result = half.mul_add(error, result); + } + result +} + +pub(super) fn square_root(value: f32) -> f32 { + let result = value * inverse_square_root(value); + // The original vector select explicitly chooses zero for a zero radicand. + if value == 0.0 { 0.0 } else { result } +} diff --git a/crates/skate-core/src/physics/grind_contact/balance.rs b/crates/skate-core/src/physics/grind_contact/balance.rs new file mode 100644 index 00000000..111e586f --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/balance.rs @@ -0,0 +1,287 @@ +//! S3 TU3 GrindBalance: 82D86318, 82D86808, 82D86C88; constructor82D8A318. +//! S2 counterparts82DD2168/82DD64D8/82DD26C0 corroborate field identities. +//! Keep these three stages in that order, after geometry correction and before +//! engagement. Independent PC arithmetic is not bit-exact Xenon emulation. +use super::{V, admission::EntryKind, arithmetic, cross, dot3, scale, sub}; +use crate::air::trajectory::grind_surface::{GeometryType, GrindSurface}; +use crate::{point_graph::PointGraph, trigonometry}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BalanceState { + pub elapsed: f32, + pub exit_angle_degrees: f32, + pub entry_delay: f32, + pub frames_away: i32, + /// Unrotated normal retained by82D86318, not the exit-lean output normal. + pub previous_normal: V, + pub exit_direction: V, +} + +impl Default for BalanceState { + fn default() -> Self { + //82D8A4BC..82D8A4EC; original vectors82139A20 and82139A10. + Self { + elapsed: 0.0, + exit_angle_degrees: 0.0, + entry_delay: 2.0, + frames_away: 21, + previous_normal: [0.0, 1.0, 0.0, 0.0], + exit_direction: [1.0, 0.0, 0.0, 0.0], + } + } +} + +/// A valid, geometry-corrected investigation, never a synthesized surface. +#[derive(Clone, Copy, Debug)] +pub struct BalanceContact<'a> { + pub surface: &'a GrindSurface, + /// Investigation+0; distinct from travel-directed investigation+32. + pub primitive_direction: V, + pub directed_grind_direction: V, + pub kind: u32, + pub entry_kind: EntryKind, +} + +#[derive(Clone, Copy, Debug)] +pub struct TargetUpInput { + pub category: u32, + /// Processed+2504, NOT current state+2508. + pub previous_state: u32, + pub board_up: V, +} + +#[derive(Clone, Copy, Debug)] +pub struct ExitLeanInput { + pub category: u32, + /// Processed+2508, unlike TargetUpInput::previous_state. + pub current_state: u32, + /// Processed+2536; value2 freezes the elapsed-time increment. + pub grind_substate: u32, + pub timestep: f32, +} + +/// Investigation+48/+64, deliberately without a fabricated default. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BalanceVectors { + pub grind_normal: V, + pub target_up: V, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ForceExitProbe { + pub start: V, + pub end: V, +} + +#[derive(Clone, Copy, Debug)] +pub struct ForceExitHit { + pub normal: V, +} + +impl BalanceState { + ///82D86318. None is the native invalid-candidate no-write branch. + pub fn update_target_up( + &mut self, + input: TargetUpInput, + contact: Option<&BalanceContact<'_>>, + vectors: &mut BalanceVectors, + ) { + let Some(contact) = contact else { return }; + let surface = contact.surface; + let normal = if input.category != 400 { + input.board_up + } else if matches!(input.previous_state, 402 | 404) { + blend( + self.previous_normal, + 0.85, + surface.upmost_normal, + f32::from_bits(0x3e19_9998), + ) + } else { + blend( + self.previous_normal, + 0.925, + surface.tilted_upmost_normal, + f32::from_bits(0x3d99_9998), + ) + }; + let direction = contact.directed_grind_direction; + let projected = cross(cross(direction, normal), direction); + let length = magnitude(projected); + vectors.grind_normal = if length <= 0.001 { + surface.upmost_normal + } else { + scale(projected, arithmetic::reciprocal(length)) + }; + self.previous_normal = vectors.grind_normal; + + let target = blend( + input.board_up, + 0.8, + surface.upmost_normal, + f32::from_bits(0x3e4c_cccc), + ); + // Unlike the projected-normal and rotation-axis branches, the original + // has NO zero-length fallback here. Do not silently invent one. + vectors.target_up = scale(target, arithmetic::reciprocal(magnitude(target))); + let alignment = dot3(vectors.grind_normal, vectors.target_up) + .max(-1.0) + .min(1.0); + let max_angle = f32::from_bits(0x3e7a_35dd); + if trigonometry::acos(alignment) > max_angle { + let axis = cross(vectors.grind_normal, vectors.target_up); + let length = magnitude(axis); + vectors.target_up = if length <= 0.001 { + vectors.grind_normal + } else { + rotate( + scale(axis, arithmetic::reciprocal(length)), + vectors.grind_normal, + max_angle, + ) + }; + } + } + + ///82D86808. Returns investigation+396 even without a valid candidate. + /// Graph is physics_grinds.ExitLeanAngleVsTime, eight X at+16/Y at+48. + pub fn update_exit_lean( + &mut self, + input: ExitLeanInput, + contact: Option<&BalanceContact<'_>>, + exit_lean_angle_vs_time: &PointGraph<8>, + vectors: &mut BalanceVectors, + ) -> f32 { + let active = input.category == 400 || input.current_state == 701 || contact.is_some(); + self.frames_away = if active { + 0 + } else { + self.frames_away.wrapping_add(1) + }; + let droppable = contact.is_some_and(|contact| { + if contact.kind == 2 { + active && contact.surface.kind != GeometryType::ThinRail + } else { + contact.surface.kind == GeometryType::Ledge + } + }); + let mut started = false; + //82D86880..94's carry/sign sequence implements a signed >20 test. + if self.frames_away > 20 { + self.exit_angle_degrees = 0.0; + self.elapsed = 0.0; + self.entry_delay = 2.0; + } else if self.elapsed > 0.0 || (active && droppable && contact.is_some()) { + if self.elapsed == 0.0 { + // Only a real contact can start the timer in this branch. + if let Some(contact) = contact { + self.entry_delay = match contact.entry_kind { + EntryKind::RideFromBelow => 0.8, + EntryKind::RideIntoCoping => 0.1, + _ => 2.0, + }; + } + started = true; + } + if input.grind_substate != 2 { + self.elapsed = input.timestep + self.elapsed; + } + } + if self.elapsed <= 0.0 { + return self.exit_angle_degrees; + } + let time = self.elapsed - self.entry_delay; + self.exit_angle_degrees = if time <= 0.0 { + 0.0 + } else { + exit_lean_angle_vs_time.evaluate(time) + }; + if let Some(contact) = contact { + let surface = contact.surface; + self.exit_direction = if surface.kind == GeometryType::ThinRail { + retain_side( + self.exit_direction, + cross(surface.upmost_normal, contact.primitive_direction), + ) + } else if started { + surface.high_side + } else { + retain_side(self.exit_direction, surface.high_side) + }; + if self.exit_angle_degrees > 0.0 { + let direction = contact.directed_grind_direction; + let axis = if cross(direction, self.exit_direction)[1] > 0.0 { + direction + } else { + negate(direction) + }; + let radians = self.exit_angle_degrees * f32::from_bits(0x3c8e_fa35); + // Both outputs rotate; previous_normal deliberately does not. + vectors.target_up = rotate(axis, vectors.target_up, radians); + vectors.grind_normal = rotate(axis, vectors.grind_normal, radians); + } + } + self.exit_angle_degrees + } + + ///82D86C88. The callback must execute the real thin nearest world-line + /// query82E0AAD0, retaining its filtering and endpoint/edge tolerances. + /// No query is issued outside (15,28]. Errors are not collision misses. + /// Updates only investigation+412 bit31, and leaves flags intact on error. + pub fn update_force_exit( + &self, + primitive_location: V, + investigation_flags: &mut u32, + mut world_line: impl FnMut(ForceExitProbe) -> Result, E>, + ) -> Result<(), E> { + let force_exit = if self.exit_angle_degrees > 28.0 { + true + } else if self.exit_angle_degrees > 15.0 { + let start = sub(primitive_location, scale(self.exit_direction, 0.1)); + let end = super::add(start, [0.0, -5.0, 0.0, 0.0]); + match world_line(ForceExitProbe { start, end })? { + Some(hit) => dot3(self.exit_direction, hit.normal) > -0.1, + None => true, + } + } else { + false + }; + *investigation_flags = (*investigation_flags & 0x7fff_ffff) | ((force_exit as u32) << 31); + Ok(()) + } +} + +fn magnitude(value: V) -> f32 { + arithmetic::square_root(dot3(value, value)) +} + +fn blend(a: V, a_weight: f32, b: V, b_weight: f32) -> V { + core::array::from_fn(|i| a[i].mul_add(a_weight, b[i] * b_weight)) +} + +fn negate(value: V) -> V { + value.map(|lane| -lane) +} + +fn retain_side(previous: V, side: V) -> V { + if dot3(previous, side) > 0.0 { + side + } else { + negate(side) + } +} + +///82C1E220 and82D86BC0..C70: quaternion half-angle rotation, not a +/// Rodrigues replacement with a different operation order. +fn rotate(axis: V, value: V, angle: f32) -> V { + let (sin, cos) = trigonometry::sin_cos(angle * 0.5); + let q = scale(axis, sin); + let first_cross = cross(q, value); + let intermediate = core::array::from_fn(|i| value[i].mul_add(cos, first_cross[i])); + let second_cross = cross(q, intermediate); + core::array::from_fn(|i| second_cross[i].mul_add(2.0, value[i])) +} + +#[cfg(test)] +#[path = "balance_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/balance_tests.rs b/crates/skate-core/src/physics/grind_contact/balance_tests.rs new file mode 100644 index 00000000..c4a516c4 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/balance_tests.rs @@ -0,0 +1,421 @@ +use super::*; + +const X: V = [1.0, 0.0, 0.0, 0.0]; +const Y: V = [0.0, 1.0, 0.0, 0.0]; +const Z: V = [0.0, 0.0, 1.0, 0.0]; + +// Explicit synthetic geometry tests branches, not a substitute runtime map. +fn surface(kind: GeometryType) -> GrindSurface { + GrindSurface { + center: [0.0; 4], + far_points: [negate(Z), Z], + upmost_normal: Y, + direction: Z, + high_side: X, + normal_limits: [0.0, 6.0], + kind, + audio_surface: 0, + physics_surface: 0, + flags: 0, + tilted_upmost_normal: Y, + } +} + +fn contact(surface: &GrindSurface, kind: u32, entry_kind: EntryKind) -> BalanceContact<'_> { + BalanceContact { + surface, + primitive_direction: Z, + directed_grind_direction: Z, + kind, + entry_kind, + } +} + +fn vectors() -> BalanceVectors { + BalanceVectors { + grind_normal: Y, + target_up: Y, + } +} + +fn exit_input(dt: f32) -> ExitLeanInput { + ExitLeanInput { + category: 100, + current_state: 100, + grind_substate: 0, + timestep: dt, + } +} + +fn graph() -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0., 10., 20., 30., 40., 50., 60., 70.], + } +} + +fn close(a: V, b: V) { + for i in 0..4 { + assert!((a[i] - b[i]).abs() < 0.00001, "lane{i}: {a:?} != {b:?}"); + } +} + +#[test] +fn constructor_and_invalid_target_preserve_the_source_history() { + let mut state = BalanceState::default(); + assert_eq!( + state, + BalanceState { + elapsed: 0., + exit_angle_degrees: 0., + entry_delay: 2., + frames_away: 21, + previous_normal: Y, + exit_direction: X + } + ); + let original = state; + let mut output = BalanceVectors { + grind_normal: X, + target_up: Z, + }; + state.update_target_up( + TargetUpInput { + category: 400, + previous_state: 402, + board_up: Y, + }, + None, + &mut output, + ); + assert_eq!(state, original); + assert_eq!( + output, + BalanceVectors { + grind_normal: X, + target_up: Z + } + ); +} + +#[test] +fn target_selects_previous_tip_state_and_separate_tilted_normal() { + let mut geometry = surface(GeometryType::Ledge); + geometry.tilted_upmost_normal = X; + let candidate = contact(&geometry, 1, EntryKind::StayInGrind); + for previous_state in [400, 402, 404] { + let mut state = BalanceState::default(); + let mut output = vectors(); + state.update_target_up( + TargetUpInput { + category: 400, + previous_state, + board_up: Y, + }, + Some(&candidate), + &mut output, + ); + if previous_state == 400 { + let raw = [f32::from_bits(0x3d99_9998), 0.925, 0., 0.]; + let expected = scale(raw, 1.0 / (raw[0] * raw[0] + raw[1] * raw[1]).sqrt()); + close(output.grind_normal, expected); + } else { + close(output.grind_normal, Y); + } + assert_eq!(state.previous_normal, output.grind_normal); + } +} + +#[test] +fn target_non_grind_uses_current_up_and_projection_fallback() { + let geometry = surface(GeometryType::ThinRail); + let candidate = contact(&geometry, 0, EntryKind::AirToGrind); + let mut state = BalanceState::default(); + let mut output = vectors(); + state.update_target_up( + TargetUpInput { + category: 200, + previous_state: 402, + board_up: X, + }, + Some(&candidate), + &mut output, + ); + close(output.grind_normal, X); + state.update_target_up( + TargetUpInput { + category: 200, + previous_state: 402, + board_up: Z, + }, + Some(&candidate), + &mut output, + ); + close(output.grind_normal, Y); // projected vector is exactly zero +} + +#[test] +fn target_clamps_to_fourteen_degrees_with_the_native_rotation() { + let geometry = surface(GeometryType::Ledge); + let candidate = contact(&geometry, 1, EntryKind::StayInGrind); + let mut state = BalanceState::default(); + let mut output = vectors(); + state.update_target_up( + TargetUpInput { + category: 400, + previous_state: 400, + board_up: X, + }, + Some(&candidate), + &mut output, + ); + let angle = 14.0_f32.to_radians(); + close(output.target_up, [angle.sin(), angle.cos(), 0., 0.]); + close(state.previous_normal, Y); + // Anti-parallel target: its zero cross axis selects grind_normal. + state.update_target_up( + TargetUpInput { + category: 400, + previous_state: 400, + board_up: negate(Y), + }, + Some(&candidate), + &mut output, + ); + close(output.target_up, Y); +} + +#[test] +fn quaternion_rotation_preserves_axis_component_and_direction() { + let half_pi = core::f32::consts::FRAC_PI_2; + close(rotate(Z, X, half_pi), Y); + close(rotate(Z, Y, half_pi), negate(X)); + close(rotate(Y, Z, half_pi), X); + close(rotate(Z, [1., 0., 2., 0.], half_pi), [0., 1., 2., 0.]); + close(rotate(Z, X, -half_pi), negate(Y)); +} + +#[test] +fn exit_entry_delays_and_tipslide_geometry_eligibility() { + for (entry, delay) in [ + (EntryKind::RideFromBelow, 0.8), + (EntryKind::RideIntoCoping, 0.1), + (EntryKind::RideFromAbove, 2.), + (EntryKind::StayInGrind, 2.), + (EntryKind::ChangeGrind, 2.), + (EntryKind::AirToGrind, 2.), + (EntryKind::DropIn, 2.), + ] { + let geometry = surface(GeometryType::Ledge); + let mut state = BalanceState::default(); + state.update_exit_lean( + exit_input(0.01), + Some(&contact(&geometry, 1, entry)), + &graph(), + &mut vectors(), + ); + assert_eq!(state.entry_delay, delay); + assert_eq!(state.elapsed, 0.01); + assert_eq!(state.frames_away, 0); + assert_eq!(state.exit_angle_degrees, 0.); + } + for geometry_type in [ + GeometryType::ThinRail, + GeometryType::FatRail, + GeometryType::Ledge, + ] { + for kind in [0, 1, 2, 3, 4, 5] { + let geometry = surface(geometry_type); + let mut state = BalanceState::default(); + state.update_exit_lean( + exit_input(0.01), + Some(&contact(&geometry, kind, EntryKind::RideFromAbove)), + &graph(), + &mut vectors(), + ); + let starts = geometry_type == GeometryType::Ledge + || (kind == 2 && geometry_type != GeometryType::ThinRail); + assert_eq!(state.elapsed > 0., starts, "kind{kind} {geometry_type:?}"); + } + } +} + +#[test] +fn exit_substate_two_freezes_time_and_zero_dt_does_not_update_side() { + let mut geometry = surface(GeometryType::Ledge); + geometry.high_side = negate(X); + let candidate = contact(&geometry, 1, EntryKind::RideIntoCoping); + let mut state = BalanceState::default(); + let mut input = exit_input(0.2); + input.grind_substate = 2; + state.update_exit_lean(input, Some(&candidate), &graph(), &mut vectors()); + assert_eq!(state.elapsed, 0.); + assert_eq!(state.entry_delay, 0.1); + assert_eq!(state.exit_direction, X); + input.grind_substate = 0; + state.update_exit_lean(input, Some(&candidate), &graph(), &mut vectors()); + assert_eq!(state.exit_direction, negate(X)); + let old_time = state.elapsed; + input.grind_substate = 2; + state.update_exit_lean(input, Some(&candidate), &graph(), &mut vectors()); + assert_eq!(state.elapsed, old_time); + let mut state = BalanceState::default(); + state.update_exit_lean(exit_input(0.), Some(&candidate), &graph(), &mut vectors()); + assert_eq!(state.exit_direction, X); +} + +#[test] +fn exit_history_retains_twenty_away_frames_then_resets_only_timers() { + let geometry = surface(GeometryType::Ledge); + let mut state = BalanceState::default(); + state.update_exit_lean( + exit_input(0.2), + Some(&contact(&geometry, 1, EntryKind::RideIntoCoping)), + &graph(), + &mut vectors(), + ); + let mut output = BalanceVectors { + grind_normal: X, + target_up: Z, + }; + for count in 1..=20 { + state.update_exit_lean(exit_input(0.01), None, &graph(), &mut output); + assert_eq!(state.frames_away, count); + assert!(state.elapsed > 0.); + } + assert_eq!( + output, + BalanceVectors { + grind_normal: X, + target_up: Z + } + ); + state.previous_normal = Z; + state.exit_direction = negate(X); + state.update_exit_lean(exit_input(0.01), None, &graph(), &mut output); + assert_eq!( + (state.elapsed, state.exit_angle_degrees, state.entry_delay), + (0., 0., 2.) + ); + assert_eq!(state.previous_normal, Z); + assert_eq!(state.exit_direction, negate(X)); + state.frames_away = i32::MAX; + state.elapsed = 1.; + state.update_exit_lean(exit_input(0.01), None, &graph(), &mut output); + assert_eq!(state.frames_away, i32::MIN); + assert!(state.elapsed > 1.); // native signed comparison, not unsigned/clamped +} + +#[test] +fn grind_category_and_current_701_keep_existing_timer_alive_without_contact() { + for (category, current_state) in [(400, 401), (700, 701)] { + let mut state = BalanceState { + elapsed: 0.5, + frames_away: 20, + ..BalanceState::default() + }; + let input = ExitLeanInput { + category, + current_state, + ..exit_input(0.1) + }; + state.update_exit_lean(input, None, &graph(), &mut vectors()); + assert_eq!(state.frames_away, 0); + assert_eq!(state.elapsed, 0.6); + } +} + +#[test] +fn exit_side_continuity_and_both_output_rotations_leave_history_unrotated() { + let mut geometry = surface(GeometryType::Ledge); + let mut state = BalanceState::default(); + let mut output = vectors(); + let angle = state.update_exit_lean( + exit_input(0.6), + Some(&contact(&geometry, 1, EntryKind::RideIntoCoping)), + &graph(), + &mut output, + ); + assert!((angle - 5.).abs() < 0.00001); + let radians = angle.to_radians(); + let expected = [-radians.sin(), radians.cos(), 0., 0.]; + close(output.grind_normal, expected); + close(output.target_up, expected); + assert_eq!(state.previous_normal, Y); + geometry.high_side = negate(X); + state.update_exit_lean( + exit_input(0.), + Some(&contact(&geometry, 1, EntryKind::StayInGrind)), + &graph(), + &mut vectors(), + ); + close(state.exit_direction, X); + // Thin rail chooses upmost cross primitive, not travel-directed direction. + geometry.kind = GeometryType::ThinRail; + let mut candidate = contact(&geometry, 0, EntryKind::StayInGrind); + candidate.directed_grind_direction = negate(Z); + state.update_exit_lean(exit_input(0.), Some(&candidate), &graph(), &mut vectors()); + close(state.exit_direction, X); + state.exit_direction = Z; // exact zero alignment takes the negated side + state.update_exit_lean(exit_input(0.), Some(&candidate), &graph(), &mut vectors()); + close(state.exit_direction, negate(X)); +} + +#[test] +fn force_exit_thresholds_query_geometry_and_other_bits() { + let location = [4., 3., 2., 0.]; + for (angle, expected, queries) in [ + (15., false, 0), + (15.001, true, 1), + (28., true, 1), + (28.001, true, 0), + ] { + let state = BalanceState { + exit_angle_degrees: angle, + ..BalanceState::default() + }; + let mut flags = 0x8123_4567; + let mut calls = 0; + state + .update_force_exit(location, &mut flags, |probe| { + calls += 1; + assert_eq!( + probe, + ForceExitProbe { + start: [3.9, 3., 2., 0.], + end: [3.9, -2., 2., 0.] + } + ); + Ok::<_, ()>(None) + }) + .unwrap(); + assert_eq!(calls, queries); + assert_eq!(flags & 0x7fff_ffff, 0x0123_4567); + assert_eq!(flags >> 31 != 0, expected); + } +} + +#[test] +fn force_exit_normal_comparison_is_strict_and_query_errors_preserve_flags() { + let state = BalanceState { + exit_angle_degrees: 20., + ..BalanceState::default() + }; + for (normal_x, force) in [(-0.10001, false), (-0.1, false), (-0.09999, true)] { + let mut flags = 0x8123_4567; + state + .update_force_exit([0.; 4], &mut flags, |_| { + Ok::<_, ()>(Some(ForceExitHit { + normal: [normal_x, 1., 0., 0.], + })) + }) + .unwrap(); + assert_eq!(flags >> 31 != 0, force); + } + let mut flags = 0x8123_4567; + assert_eq!( + state.update_force_exit([0.; 4], &mut flags, |_| Err("world unavailable")), + Err("world unavailable") + ); + assert_eq!(flags, 0x8123_4567); +} diff --git a/crates/skate-core/src/physics/grind_contact/control.rs b/crates/skate-core/src/physics/grind_contact/control.rs index 79652bf9..5f8fedc1 100644 --- a/crates/skate-core/src/physics/grind_contact/control.rs +++ b/crates/skate-core/src/physics/grind_contact/control.rs @@ -34,16 +34,16 @@ impl Control { let mut target = input; if tilt.abs() > 0.26 { target = if selected { - input.max(0.0) - } else { input.min(0.0) + } else { + input.max(0.0) }; } if height < -0.25 { target = if selected { - input.min(0.0) - } else { input.max(0.0) + } else { + input.min(0.0) }; } let sign = (if front { 1.0 } else { -1.0 }) * if switched { -1.0 } else { 1.0 }; @@ -58,7 +58,10 @@ impl Control { if hanging_back { target = (target + sign * 0.5).clamp(-1.0, 1.0); } - let yaw = (deadzone(translation) * 0.85 + deadzone(nudge) * 0.79).clamp(-0.85, 0.85); + //82D8A238/240: round the nudge product, then fused translation. + let yaw = deadzone(translation) + .mul_add(0.85, deadzone(nudge) * 0.79) + .clamp(-0.85, 0.85); (yaw * yaw * yaw, target * target * target, target != input) } else { (0.0, 0.0, false) diff --git a/crates/skate-core/src/physics/grind_contact/entry.rs b/crates/skate-core/src/physics/grind_contact/entry.rs index bb3a3885..12a9ec72 100644 --- a/crates/skate-core/src/physics/grind_contact/entry.rs +++ b/crates/skate-core/src/physics/grind_contact/entry.rs @@ -1,6 +1,103 @@ //! TU3 82D86F00 airborne entry and 82D872B8 impact admission. //! The caller retains this result until Grind::PreUpdate82D40AF8 consumes it. -use super::{V, dot3}; +use super::{V, dot3, arithmetic, admission::engagement_slope_sine}; +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct Input<'a> { + pub valid: bool, + pub kind: u32, + pub category: u32, + pub previous_state_2504: u32, + pub speed: f32, + pub balance_2720: f32, + pub direction: V, + pub normal: V, + pub up: V, + pub board_velocity: V, + pub air_velocity: V, + pub surface_kind: u32, + pub high_side: V, + /// physics_grinds/VertEngagementHelpVsVelY, X144/Y160. + pub vertical_help: &'a PointGraph<4>, + /// Stock wipeout collection layout256, checked by82D872B8. + pub max_delta: f32, + pub flags: u32, + pub previous_entry_velocity: V, +} + +#[derive(Clone, Debug)] +pub struct Output { + pub valid: bool, + pub flags: u32, + pub entry_velocity: V, + pub impact_speed: f32, + /// Wipeout reason offsets minus20. Each request also sets input2468 bit18. + pub wipeout_reasons: Vec, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Engagement { pub tipslide_frames: u32 } + +impl Engagement { + ///82D86DE8: retain flags/velocity when invalid; reset impact every update. + pub fn update(&mut self, input: Input<'_>) -> Output { + self.tipslide_frames = if input.valid && input.kind == 2 + && matches!(input.previous_state_2504, 701 | 403) && input.speed > 1.8 { + 8 + } else { + let next = self.tipslide_frames.wrapping_sub(1); + if (next as i32) < 0 { 0 } else { next } + }; + let mut out = Output { valid: input.valid, + flags: (input.flags & !0x0200_0000) | if self.tipslide_frames > 0 { 0x0200_0000 } else { 0 }, + entry_velocity: input.previous_entry_velocity, impact_speed: 0., wipeout_reasons: Vec::new() }; + if !out.valid { return out; } + out.flags &= !0x4000_0000; + if !matches!(input.category, 100 | 200) { return out; } + if dot3(input.up, input.direction).abs() > 0.5 { + out.valid = false; + if input.category == 200 { out.wipeout_reasons.push(13); } + return out; + } + out.flags |= 0x4000_0000; + let initial = if input.category == 100 { input.board_velocity } else { input.air_velocity }; + out.entry_velocity = if input.category == 100 { + grounded_velocity(input.kind, input.direction, input.board_velocity, + input.balance_2720, input.speed, input.vertical_help) + } else { airborne_velocity(input.kind, input.direction, input.normal, input.air_velocity) }; + let delta = core::array::from_fn(|i| out.entry_velocity[i] - initial[i]); + out.impact_speed = arithmetic::square_root(dot3(delta, delta)); + // MeasureEngagement runs for BOTH ground and air accepted entries. + if out.impact_speed > input.max_delta { out.wipeout_reasons.push(9); } + let along = dot3(input.board_velocity, input.direction); + let mut transverse = core::array::from_fn(|i| input.direction[i] * along - input.board_velocity[i]); + transverse[1] = 0.; + if dot3(transverse, transverse) > 49. + && (input.surface_kind == 0 || dot3(input.board_velocity, input.high_side) > 0.) { + out.wipeout_reasons.push(14); + } + if !out.wipeout_reasons.is_empty() { out.valid = false; } + out + } +} + +pub fn grounded_velocity(kind: u32, direction: V, velocity: V, + balance: f32, speed: f32, vertical_help: &PointGraph<4>) -> V { + let base = match kind { + 1 => 0.1, + 2 | 4 if balance != 0. && speed < 1.45 => 1., + 2 => 0.4, + 0 | 3 | 4 | 5 => 0.2, + _ => 0., + }; + let help = vertical_help.evaluate(engagement_slope_sine(direction, velocity)); + let amount = help.mul_add(1. - base, base); + // Native fsel on (1-amount), including unordered selecting1. + let amount = if 1. - amount >= 0. { amount } else { 1. }; + let along = dot3(velocity, direction); + core::array::from_fn(|i| (direction[i] * along).mul_add(amount, velocity[i] * (1. - amount))) +} pub fn airborne_velocity(kind: u32, direction: V, normal: V, velocity: V) -> V { let across = cross(normal, direction); @@ -25,7 +122,7 @@ pub fn airborne_rejections( if dot3(up, direction).abs() > 0.5 { return vec![13]; } let delta = core::array::from_fn(|i| corrected[i] - air_velocity[i]); let mut reasons = Vec::new(); - if dot3(delta, delta).sqrt() > max_delta { reasons.push(9); } + if arithmetic::square_root(dot3(delta, delta)) > max_delta { reasons.push(9); } let along = dot3(board_velocity, direction); let mut transverse = core::array::from_fn(|i| direction[i] * along - board_velocity[i]); transverse[1] = 0.0; @@ -38,3 +135,7 @@ fn cross(a: V, b: V) -> V { [(-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]), (-a[1]).mul_add(b[0],a[0]*b[1]),0.0] } + +#[cfg(test)] +#[path = "entry_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/entry_tests.rs b/crates/skate-core/src/physics/grind_contact/entry_tests.rs new file mode 100644 index 00000000..2b4a679a --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/entry_tests.rs @@ -0,0 +1,68 @@ +use super::*; + +const HELP: PointGraph<4> = PointGraph { x: [-1., 0., 0.5, 1.], y: [0.; 4] }; +fn input() -> Input<'static> { + Input { valid: true, kind: 0, category: 200, previous_state_2504: 200, + speed: 4., balance_2720: 0., direction: [1., 0., 0., 0.], + normal: [0., 1., 0., 0.], up: [0., 1., 0., 0.], + board_velocity: [4., -2., 3., 0.], air_velocity: [4., -2., 3., 0.], + surface_kind: 0, high_side: [0., 0., 1., 0.], vertical_help: &HELP, + max_delta: 100., flags: 0, previous_entry_velocity: [9.; 4] } +} + +#[test] +fn ground_and_air_use_different_velocity_projections() { + let mut state = Engagement::default(); + let air = state.update(input()); + let ground = state.update(Input { category: 100, ..input() }); + assert!((air.entry_velocity[1] + 2.).abs() < 0.00001); + assert!((air.entry_velocity[2] - 1.8).abs() < 0.00001); + assert!((ground.entry_velocity[1] + 1.6).abs() < 0.00001); + assert!((ground.entry_velocity[2] - 2.4).abs() < 0.00001); +} + +#[test] +fn steep_ground_entry_rejects_without_air_wipeout() { + let steep = Input { up: [1., 0., 0., 0.], ..input() }; + let mut state = Engagement::default(); + let air = state.update(steep); + assert!(!air.valid); + assert_eq!(air.wipeout_reasons, [13]); + let ground = state.update(Input { category: 100, ..steep }); + assert!(!ground.valid); + assert!(ground.wipeout_reasons.is_empty()); + assert_eq!(ground.flags & 0x4000_0000, 0); +} + +#[test] +fn both_impact_requests_survive_first_rejection_including_on_ground() { + let mut state = Engagement::default(); + let result = state.update(Input { category: 100, max_delta: 0., + board_velocity: [4., 0., 8., 0.], ..input() }); + assert!(!result.valid); + assert_eq!(result.wipeout_reasons, [9, 14]); +} + +#[test] +fn tipslide_latch_counts_down_during_invalid_investigations() { + let mut state = Engagement::default(); + let result = state.update(Input { kind: 2, previous_state_2504: 403, ..input() }); + assert_ne!(result.flags & 0x0200_0000, 0); + assert_eq!(state.tipslide_frames, 8); + for remaining in (0..8).rev() { + let result = state.update(Input { valid: false, ..input() }); + assert_eq!(state.tipslide_frames, remaining); + assert_eq!(result.entry_velocity, [9.; 4]); + assert_eq!(result.impact_speed, 0.); + } + state.update(Input { valid: false, ..input() }); + assert_eq!(state.tipslide_frames, 0); +} + +#[test] +fn coping_and_entry_slope_dead_zones_are_distinct() { + let velocity = [1., 0.005, 0., 0.]; + let tangent = [1., 0., 0., 0.]; + assert_eq!(super::super::admission::approach_slope_sine(tangent, velocity), 0.); + assert!((engagement_slope_sine(tangent, velocity) - 1.).abs() < 0.00001); +} diff --git a/crates/skate-core/src/physics/grind_contact/families.rs b/crates/skate-core/src/physics/grind_contact/families.rs index 3b3c194b..fedf27f2 100644 --- a/crates/skate-core/src/physics/grind_contact/families.rs +++ b/crates/skate-core/src/physics/grind_contact/families.rs @@ -1,11 +1,13 @@ //! Additional TU3 deck/truck contact queries and family admission leaves. use super::*; +use super::admission::{Admission, EntryKind}; #[derive(Clone, Copy, Debug)] pub struct Contact { pub geometry: FiftyFiftyCandidate, pub front: bool, pub kind: u32, + pub entry_kind: EntryKind, } fn rectangle(a: V, b: V, c: V, d: V, reference: V, edges: &[Primitive]) -> Option { @@ -79,7 +81,7 @@ pub fn tip_contacts( fn geometry(hit: TruckContact, edge: Primitive) -> FiftyFiftyCandidate { let delta = sub(edge.end, edge.start); FiftyFiftyCandidate { - direction: scale(delta, dot3(delta, delta).sqrt().recip()), + direction: scale(delta, arithmetic::inverse_square_root(dot3(delta, delta))), centre: hit.position, front: edge.end, rear: edge.start, @@ -121,13 +123,19 @@ pub fn darkslide( velocity: V, category: u32, low_wheel_frames: u32, + admission: &Admission<'_>, ) -> Option { - let g = geometry(hit, edge); - if dot3(upright_normal(g.direction), scale(board[1], -1.0)) <= 0.65 { + let mut g = geometry(hit, edge); + let delta = sub(edge.end, edge.start); + g.direction = scale(delta, arithmetic::reciprocal(arithmetic::square_root(dot3(delta, delta)))); + if dot3(upright_normal(g.direction), scale(board[1], -1.0)) <= 0.45 { return None; } + //82D8907C deliberately tests state400, even for darkslide kind5. + let decision = admission.test(400, g.direction, false); + if !decision.allowed { return None; } let depth = dot3(sub(board[3], hit.position), board[1]); - if depth * depth >= f32::from_bits(0x3b6bedfa) { + if dot3(scale(board[1], depth), scale(board[1], depth)) >= 0.005625 { return None; } if category == 100 && dot3(velocity, g.direction).abs() <= 0.75 && low_wheel_frames <= 10 { @@ -137,6 +145,7 @@ pub fn darkslide( geometry: g, front: false, kind: 5, + entry_kind: decision.kind, }) } @@ -151,6 +160,7 @@ pub fn five_o( flags_2472: u32, ground_frames: u32, translation: f32, + admission: &Admission<'_>, ) -> Option { if flags_2476 & 0x4000_0000 != 0 { return None; @@ -159,14 +169,13 @@ pub fn five_o( hit.and_then(|hit| { let g = geometry(hit, edges[hit.primitive]); let depth = dot3(sub(board[3], hit.position), board[1]); - if depth * depth >= f32::from_bits(0x3c8a71de) + let projected = scale(board[1], depth); + if dot3(projected, projected) >= f32::from_bits(0x3c8a71de) { return None; } + let decision = admission.test(403, g.direction, true); + if !decision.allowed || dot3(board[0], g.direction).abs() >= 0.906 - || dot3(board[1], g.direction).abs() >= 0.46 - { - None - } else { - Some(g) - } + || dot3(board[1], g.direction).abs() >= 0.46 { return None; } + Some((g, decision.kind)) }) }); let forwards = dot3(board[2], velocity) > 0.0; @@ -194,9 +203,10 @@ pub fn five_o( return None; }; Some(Contact { - geometry: valid[index]?, + geometry: valid[index]?.0, front: index == 0, kind: 3, + entry_kind: valid[index]?.1, }) } @@ -213,6 +223,7 @@ pub fn tipslide( ground_frames: u32, balance: f32, reference_right: V, + admission: &Admission<'_>, ) -> Option { if flags_2476 & 0x4000_0000 != 0 { return None; @@ -220,12 +231,16 @@ pub fn tipslide( let front = hits[0].is_some(); let hit = if front { hits[0]? } else { hits[1]? }; let g = geometry(hit, edges[hit.primitive]); - if velocity[1] < 0.0 && category != 400 && board[3][1] <= hit.position[1] { + let decision = admission.test(402, g.direction, true); + if !decision.allowed { return None; } + if (decision.kind == EntryKind::DropIn || velocity[1] < 0.0 && category != 400) + && board[3][1] <= hit.position[1] { return None; } let delta = sub(board[3], hit.position); let depth = dot3(delta, board[1]); - if depth * depth >= f32::from_bits(0x3b6bedfa) { + let projected = scale(board[1], depth); + if dot3(projected, projected) >= f32::from_bits(0x3b6bedfa) { return None; } let perpendicular = sub(velocity, scale(g.direction, dot3(g.direction, velocity))); @@ -245,5 +260,6 @@ pub fn tipslide( geometry: g, front, kind: 2, + entry_kind: decision.kind, }) } diff --git a/crates/skate-core/src/physics/grind_contact/investigator.rs b/crates/skate-core/src/physics/grind_contact/investigator.rs new file mode 100644 index 00000000..afa82051 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/investigator.rs @@ -0,0 +1,104 @@ +//! Family/proximity arbitration from S3 82D875A8. +//! Geometry refinement and balance/engagement run afterward in the manager. +use super::{admission::Admission, families::{self, Contact}, *}; + +#[derive(Clone, Copy, Debug)] +pub struct Query<'a> { + pub board: [V; 4], + pub admission: Admission<'a>, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2484: u32, + pub tip_state: u32, + pub truck_to_wheel: f32, + pub deck_to_truck: f32, + pub test_above: f32, + pub test_below: f32, + pub translation: f32, + pub stability_nudge: f32, + pub balance: f32, + pub reference_right: V, + pub ground_frames: u32, + pub low_wheel_frames: u32, + /// Grind permission OR retained70-frame exclusion while published kind!=2. + pub forbidden: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct Proximity { + pub contact: TruckContact, + /// Investigator412 bit26: deck/inverted rather than truck contact. + pub deck_contact: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Investigation { + pub candidate: Option, + /// Only the fallback path sets412 bit27. It does not authorize a grind. + pub proximity: Option, +} + +/// `edges` is already the native bounded query result, in its selection order. +pub fn investigate(query: &Query<'_>, edges: &[Primitive]) -> Investigation { + if edges.is_empty() { return Investigation::default(); } + let q = query; + let a = &q.admission; + let trucks = truck_contacts(q.board, q.flags_2484, q.truck_to_wheel, q.deck_to_truck, edges); + let tips = families::tip_contacts(q.board, q.flags_2484, q.tip_state, q.flags_2468, q.deck_to_truck, edges); + let deck = deck_contact(q.board, q.flags_2484, q.test_above, q.test_below, q.deck_to_truck, edges); + let inverted = families::inverted_contact(q.board, q.flags_2484, q.test_above, q.test_below, edges); + + if !q.forbidden { + let fifty = || { + let geometry = fifty_fifty_candidate_on_splines(q.board, + [q.translation, q.stability_nudge], trucks, edges)?; + let decision = a.test(401, geometry.direction, false); + decision.allowed.then_some(Contact { geometry, front: false, kind: 0, entry_kind: decision.kind }) + }; + let five = || families::five_o(q.board, trucks, edges, a.velocity, + q.flags_2476, q.flags_2472, q.ground_frames, q.translation, a); + let tip = || families::tipslide(q.board, tips, edges, a.velocity, a.category, + q.flags_2476, q.flags_2472, q.ground_frames, q.balance, q.reference_right, a); + let slide = || { + let hit = deck?; + let geometry = boardslide_candidate(q.board, hit, edges[hit.primitive], a.velocity, + a.category, q.low_wheel_frames as i32, q.flags_2476, q.deck_to_truck, q.truck_to_wheel, a)?; + Some(Contact { geometry, front: false, kind: 1, + entry_kind: a.test(400, geometry.direction, false).kind }) + }; + let dark = || { + let hit = inverted?; + families::darkslide(q.board, hit, edges[hit.primitive], a.velocity, + a.category, q.low_wheel_frames, a) + }; + // The original retries the truck family as 50-50 first, even in403. + let retained = match a.state { + 401 | 403 => fifty().or_else(five), + 402 | 404 => tip(), + 400 => slide(), + 405 => dark(), + _ => None, + }; + if let Some(candidate) = retained.or_else(fifty).or_else(five) + .or_else(tip).or_else(slide).or_else(dark) { + return Investigation { candidate: Some(candidate), proximity: None }; + } + } + //82D87C5C..82D87E60: keep contact-only metadata even when entry is denied. + let proximity = fallback(trucks, deck, inverted, tips); + Investigation { candidate: None, proximity } +} + +fn fallback(trucks: [Option; 2], deck: Option, + inverted: Option, tips: [Option; 2]) -> Option { + //82D87D24 short-circuits: v82 becomes1 only on an actual deck hit. + trucks[0].or(trucks[1]).map(|contact| Proximity { contact, deck_contact: false }) + .or_else(|| deck.map(|contact| Proximity { contact, deck_contact: true })) + .or_else(|| inverted.or(tips[0]).or(tips[1]) + .map(|contact| Proximity { contact, deck_contact: false })) +} + +#[cfg(test)] +#[path = "investigator_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/investigator_tests.rs b/crates/skate-core/src/physics/grind_contact/investigator_tests.rs new file mode 100644 index 00000000..f89d8640 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/investigator_tests.rs @@ -0,0 +1,20 @@ +use super::*; +fn hit(primitive: usize) -> Option { + Some(TruckContact { position: [0.; 4], primitive }) +} +#[test] +fn fallback_keeps_native_contact_order_and_classifies_only_ordinary_deck() { + let result = fallback([hit(1), hit(2)], hit(3), hit(4), [hit(5), hit(6)]).unwrap(); + assert_eq!(result.contact.primitive, 1); + assert!(!result.deck_contact); + let result = fallback([None; 2], hit(3), hit(4), [hit(5), hit(6)]).unwrap(); + assert_eq!(result.contact.primitive, 3); + assert!(result.deck_contact); + let result = fallback([None; 2], None, hit(4), [hit(5), hit(6)]).unwrap(); + assert_eq!(result.contact.primitive, 4); + assert!(!result.deck_contact); + let result = fallback([None; 2], None, None, [None, hit(6)]).unwrap(); + assert_eq!(result.contact.primitive, 6); + assert!(!result.deck_contact); + assert!(fallback([None; 2], None, None, [None; 2]).is_none()); +} diff --git a/crates/skate-core/src/physics/grind_contact/manager.rs b/crates/skate-core/src/physics/grind_contact/manager.rs new file mode 100644 index 00000000..314fa886 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/manager.rs @@ -0,0 +1,140 @@ +//! Manager leaves recovered from original TU3, independent of host storage. +use super::{arithmetic, dot3, scale, sub, V}; +use crate::point_graph::PointGraph; + +///82D87460, after CalcNormalizedGrindDir82D37048. +pub fn directed_tangent(start: V, end: V, velocity: V, previous: V) -> V { + let delta = sub(end, start); + let mut direction = scale(delta, arithmetic::reciprocal(arithmetic::square_root(dot3(delta, delta)))); + let speed = dot3(direction, velocity); + if speed < 0. { direction = scale(direction, -1.); } + if speed.abs() < 0.1 && dot3(previous, direction) < -0.9 { + direction = scale(direction, -1.); + } + direction +} + +#[derive(Clone, Copy, Debug)] +pub struct GeometryInput { + pub valid: bool, + pub family: u32, + pub current_state: u32, + pub category: u32, + pub air_frames: u32, + pub geometry_kind: u32, + pub geometry_flags: u32, + pub far_points: [V; 2], + pub upmost: V, + pub high_side: V, + pub point: V, + pub direction: V, + pub board_position: V, + pub board_forward: V, + pub velocity: V, + pub deck_to_truck: f32, + pub previous_exit_angle: f32, + pub previous_exit_direction: V, + pub flags: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct GeometryOutput { pub valid: bool, pub family: u32, pub flags: u32 } + +///82D880D0, with the retained one-truck exclusion owned by the investigator. +pub fn tweak_geometry(i: GeometryInput, avoid_five_o_frames: &mut u32) -> GeometryOutput { + let mut o = GeometryOutput { valid: i.valid, family: i.family, flags: i.flags }; + if i.geometry_kind == 3 + || i.family == 2 && i.geometry_flags & 0x1000_0000 != 0 && i.category != 400 && i.air_frames < 20 + || i.family == 1 && i.geometry_flags & 0x1000_0000 != 0 { o.valid = false; } + if o.valid && is_backslash(&i) { o.family = 4; } + if o.valid && i.previous_exit_angle > 0. && i.geometry_kind != 2 + && dot3(i.previous_exit_direction, i.high_side) < 0. { o.valid = false; } + if o.valid && i.geometry_kind != 0 { + let mut transverse = sub(scale(i.direction, dot3(i.direction, i.velocity)), i.velocity); + transverse[1] = 0.; + if dot3(transverse, transverse) > 12.25 && dot3(i.velocity, i.high_side) < 0. { o.valid = false; } + } + // Source executes this branch even if an earlier check rejected validity. + if o.family == 3 && i.geometry_kind == 2 { + if (*avoid_five_o_frames as i32) > 0 { o.valid = false; } + else { + let hanging = if o.flags & 0x2000_0000 != 0 { scale(i.board_forward, -1.) } else { i.board_forward }; + if dot3(hanging, i.high_side) > 0. { + let vertical = dot3(hanging, i.upmost); + o.flags = (o.flags & !0x1000_0000) | if vertical < 0.19 { 0x1000_0000 } else { 0 }; + if vertical < 0. { o.valid = false; *avoid_five_o_frames = 30; } + } + } + } + o +} + +///82D883E0: height DIFFERENCE chooses the high-side sample, not two positive heights. +fn is_backslash(i: &GeometryInput) -> bool { + if i.family != 2 || i.current_state == 402 || i.geometry_flags & 0x8000_0000 != 0 { return false; } + let offsets = i.far_points.map(|p| sub(p, i.point)); + let heights = offsets.map(|v| dot3(v, i.upmost)); + if (heights[0] - heights[1]).abs() <= i.deck_to_truck * 0.1 { return false; } + let high = if heights[0] > heights[1] { offsets[0] } else { offsets[1] }; + dot3(sub(i.board_position, i.point), high) > 0. +} + +///82D8ACF0 / S2 ManageCopingAssistance82DDBC48. Update, then decrement, even +///on the activation frame. FrictionVsTime is a different pre-update producer. +pub fn gravity_relief(timer: &mut f32, valid: bool, category: u32, + tangent: V, velocity: V, dt: f32, vertical: &PointGraph<4>, linear: &PointGraph<4>) -> f32 { + if valid && category == 100 + && super::admission::engagement_slope_sine(tangent, velocity) > 0.85 { + *timer = vertical.evaluate(velocity[1]) * linear.evaluate(dot3(tangent, velocity).abs()); + } + let next = *timer - dt; + *timer = if -next >= 0. { 0. } else { next }; + *timer +} + +#[derive(Clone, Copy, Debug)] +pub struct JumpGeometry { + pub geometry_kind: u32, + pub high_side: V, + pub normal: V, + pub direction: V, + pub upmost: V, + pub point: V, +} + +#[derive(Clone, Copy, Debug)] +pub struct Jumper { + pub launched: bool, + pub cooldown: u32, + pub family: u32, + pub energy: f32, + pub geometry: JumpGeometry, +} + +impl Default for Jumper { + ///82D8A318; original vector constants82139A10/82139A20 checked in memory. + fn default() -> Self { + Self { launched: false, cooldown: 0, family: 3, energy: 1., geometry: JumpGeometry { + geometry_kind: 0, high_side: [1., 0., 0., 0.], normal: [0., 1., 0., 0.], + direction: [1., 0., 0., 0.], upmost: [0., 1., 0., 0.], point: [0.; 4] } } + } +} + +impl Jumper { + ///82D739D8: invalid investigation retains geometry. Return a flag to OR, + ///not a replacement for the complete processed flags2476 word. + pub fn update(&mut self, geometry: Option, published_family: u32) -> u32 { + self.launched = false; + let next = self.cooldown.wrapping_sub(1); + self.cooldown = if (next as i32) < 0 { 0 } else { next }; + if let Some(geometry) = geometry { self.geometry = geometry; } + if published_family != u32::MAX { self.family = published_family; } + let energy = self.energy + 0.0035; + self.energy = if 1. - energy >= 0. { energy } else { 1. }; + if self.cooldown > 0 { 0x0200_0000 } else { 0 } + } +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/grind_contact/manager_tests.rs b/crates/skate-core/src/physics/grind_contact/manager_tests.rs new file mode 100644 index 00000000..be81f1fe --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/manager_tests.rs @@ -0,0 +1,62 @@ +use super::*; + +fn geometry() -> GeometryInput { + GeometryInput { valid: true, family: 2, current_state: 100, category: 100, + air_frames: 0, geometry_kind: 2, geometry_flags: 0, + far_points: [[0., 0.1, 0.2, 0.], [0., -0.1, -0.2, 0.]], + upmost: [0., 1., 0., 0.], high_side: [0., 0., 1., 0.], + point: [0.; 4], direction: [1., 0., 0., 0.], + board_position: [0., 0.1, 0.2, 0.], board_forward: [1., 0., 0., 0.], + velocity: [1., 0., 0., 0.], deck_to_truck: 0.243, + previous_exit_angle: 0., previous_exit_direction: [1., 0., 0., 0.], flags: 0 } +} + +#[test] +fn backslash_uses_height_difference_and_preserves_existing_tipslide() { + let mut counter = 0; + assert_eq!(tweak_geometry(geometry(), &mut counter).family, 4); + assert_eq!(tweak_geometry(GeometryInput { current_state: 402, ..geometry() }, &mut counter).family, 2); + assert_eq!(tweak_geometry(GeometryInput { board_position: [0., 0., -1., 0.], ..geometry() }, &mut counter).family, 2); +} + +#[test] +fn five_o_hanging_deck_sets_pry_then_excludes_thirty_frames() { + let mut counter = 0; + let input = GeometryInput { family: 3, board_forward: [0., -0.1, 0.9, 0.], ..geometry() }; + let result = tweak_geometry(input, &mut counter); + assert!(!result.valid); + assert_ne!(result.flags & 0x1000_0000, 0); + assert_eq!(counter, 30); + assert!(!tweak_geometry(GeometryInput { board_forward: [1., 0., 0., 0.], ..input }, &mut counter).valid); +} + +#[test] +fn coping_timer_is_reduced_on_activation_and_expires_without_contact() { + let vertical = PointGraph { x: [-1., 0., 1., 2.], y: [2.; 4] }; + let linear = PointGraph { x: [0., 1., 2., 3.], y: [0.5; 4] }; + let mut timer = 0.; + let tangent = [1., 0., 0., 0.]; + let velocity = [3., 1., 0., 0.]; + assert_eq!(gravity_relief(&mut timer, true, 100, tangent, velocity, 0.25, &vertical, &linear), 0.75); + assert_eq!(gravity_relief(&mut timer, false, 200, tangent, velocity, 1., &vertical, &linear), 0.); +} + +#[test] +fn jumper_retains_geometry_and_regenerates_per_update_not_per_second() { + let mut jumper = Jumper { cooldown: 2, energy: 0.4, ..Jumper::default() }; + assert_eq!(jumper.update(None, u32::MAX), 0x0200_0000); + assert_eq!(jumper.family, 3); + assert_eq!(jumper.geometry.normal, [0., 1., 0., 0.]); + assert_eq!(jumper.energy, 0.4 + 0.0035); + assert_eq!(jumper.update(None, 5), 0); + assert_eq!(jumper.family, 5); +} + +#[test] +fn directed_rail_retains_previous_sign_only_at_low_speed() { + let start = [0.; 4]; + let end = [1., 0., 0., 0.]; + let previous = [-1., 0., 0., 0.]; + assert!(directed_tangent(start, end, [0.05, 0., 0., 0.], previous)[0] < 0.); + assert!(directed_tangent(start, end, [0.1, 0., 0., 0.], previous)[0] > 0.); +} diff --git a/crates/skate-core/src/physics/grind_contact/tests.rs b/crates/skate-core/src/physics/grind_contact/tests.rs new file mode 100644 index 00000000..0561b603 --- /dev/null +++ b/crates/skate-core/src/physics/grind_contact/tests.rs @@ -0,0 +1,54 @@ +use super::*; +use crate::point_graph::PointGraph; + +const BOARD: [V; 4] = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.;4]]; +const GRAPH: PointGraph<4> = PointGraph { x: [0.,0.3,0.7,1.], y: [1.;4] }; +fn admission(category: u32, state: u32) -> admission::Admission<'static> { + admission::Admission { category, state, speed: 2., velocity: [0.,0.,2.,0.], threshold_vs_slope: &GRAPH } +} +fn rail(start: V, end: V) -> Primitive { Primitive { start, end, owner: 1 } } + +#[test] +fn one_truck_admission_happens_before_front_rear_selection() { + let edges = [rail([0.;4],[0.5,0.,0.8660254,0.]), rail([0.;4],[0.,0.,1.,0.])]; + let hits = [Some(TruckContact { position: [0.,0.,0.2,0.], primitive: 0 }), + Some(TruckContact { position: [0.,0.,-0.2,0.], primitive: 1 })]; + let result = families::five_o(BOARD, hits, &edges, [0.,0.,2.,0.], 0,0,0,1., &admission(400,400)).unwrap(); + assert!(!result.front); + assert_eq!(result.geometry.primitive, 1); + assert_eq!(result.entry_kind, admission::EntryKind::ChangeGrind); +} + +#[test] +fn tipslide_drop_in_rejects_contact_above_deck_without_descent() { + let edges = [rail([0.;4],[0.,0.,1.,0.])]; + let hit = Some(TruckContact { position: [0.,0.01,0.2,0.], primitive: 0 }); + let input = admission::Admission { speed: 1., velocity: [0.,0.1,0.,0.], ..admission(100,100) }; + assert!(families::tipslide(BOARD,[hit,None],&edges,input.velocity,100,0,0,0,0.,BOARD[0],&input).is_none()); +} + +#[test] +fn darkslide_uses_its_own_tilt_and_depth_thresholds() { + let edges = [rail([0.;4],[0.,0.,1.,0.])]; + let board = [[-0.6,-0.8,0.,0.],[0.8,-0.6,0.,0.],[0.,0.,1.,0.],[0.056,-0.042,0.,0.]]; + let hit = TruckContact { position: [0.;4], primitive: 0 }; + let result = families::darkslide(board,hit,edges[0],[0.,0.,2.,0.],200,0,&admission(200,201)); + assert!(result.is_some()); + let mut deeper = board; + deeper[3] = scale(board[1], 0.08); + assert!(families::darkslide(deeper,hit,edges[0],[0.,0.,2.,0.],200,0,&admission(200,201)).is_none()); +} + +#[test] +fn one_truck_clamps_match_both_originals_not_zip_reversed_signs() { + for (front, tilt, input, expected) in [ + (true,-0.3,0.5,0.), (true,-0.3,-0.5,-0.011249996), + (false,0.3,-0.5,0.), (false,0.3,0.5,0.011249996), + (true,0.3,-0.5,0.), (true,0.3,0.5,0.011249996), + (false,-0.3,0.5,0.), (false,-0.3,-0.5,-0.011249996), + ] { + let mut control = control::Control::default(); + control.update(3,[0.,0.,1.,0.],[0.,1.,tilt,0.],front,false,false,0.,0.,input,0.); + assert!((control.pitch-expected).abs()<1e-7, "front={front} tilt={tilt} input={input}: {}",control.pitch); + } +} diff --git a/crates/skate-core/src/physics/grind_forces.rs b/crates/skate-core/src/physics/grind_forces.rs index ebfadca1..0fafadfa 100644 --- a/crates/skate-core/src/physics/grind_forces.rs +++ b/crates/skate-core/src/physics/grind_forces.rs @@ -1,10 +1,29 @@ //! TU3 grind force leaves. Their inputs belong to the retained grind manager; //! contact admission and lifecycle are separate from these force calculations. use super::native_arithmetic::dot3; -type V = [f32; 4]; +pub type V = [f32; 4]; +#[path = "grind_forces/slide.rs"] +pub mod slide; +#[path = "grind_forces/release.rs"] +pub mod release; +#[path = "grind_forces/noise.rs"] +pub mod noise; +#[path = "grind_forces/post.rs"] +pub mod post; +#[path = "grind_forces/launch.rs"] +pub mod launch; +#[path = "grind_forces/orientation.rs"] +pub mod orientation; +#[path = "grind_forces/support.rs"] +pub mod support; +#[path = "grind_forces/reckoning.rs"] +pub mod reckoning; +#[cfg(test)] +#[path = "grind_forces/tests.rs"] +mod tests; ///82D3FA18, called by the 50-50 update82D41D70 with800,0,.07. -/// Returns a force at the deck origin; never moves the body to a spline. +/// Returns a force at the deck origin; None means no accumulator call. pub fn lateral_pin( board: [V; 4], point: V, @@ -15,7 +34,7 @@ pub fn lateral_pin( up_offset: f32, forward_selected: bool, slope_multiplier: f32, -) -> V { +) -> Option { let direction = if forward_selected { board[2] } else { @@ -25,16 +44,16 @@ pub fn lateral_pin( direction[i].mul_add(forward_offset, board[3][i]) - board[1][i] * up_offset }); let force = scale(across, dot3(sub(point, reference), across) * strength); - let length = dot3(force, force).sqrt(); - if length <= 0.0 { - return [0.0; 4]; + let length = length(force); + if !(length > 0.0) { + return None; } - let axis = scale(force, length.recip()); + let axis = scale(force, reciprocal(length)); let damping = scale( axis, strength * f32::from_bits(0x3e08_3127) * dot3(velocity, axis), ); - scale(sub(force, damping), slope_multiplier) + Some(scale(sub(force, damping), slope_multiplier)) } ///82D3FD88 after its native surface/material selection. It damps velocity in @@ -46,15 +65,15 @@ pub fn friction( time_multiplier: f32, flagged_surface: bool, surface_multiplier: f32, - engagement: u32, + geometry_kind: u32, strengths: [f32; 3], ) -> V { let tangent_velocity = sub(velocity, scale(grind_normal, dot3(velocity, grind_normal))); - let speed = dot3(tangent_velocity, tangent_velocity).sqrt(); + let speed = length(tangent_velocity); if speed <= 0.001 { return [0.0; 4]; } - let strength = strengths[match engagement { + let strength = strengths[match geometry_kind { 0 => 0, 1 => 1, _ => 2, @@ -77,128 +96,44 @@ fn scale(a: V, scale: f32) -> V { a.map(|v| v * scale) } -///Boardslide82D419A0, static rail branch (investigation kind!=2). -///Returns the native point forces in order. Translation comes from the stock -///PhysGrindTranslation animation attribute, not controller magnitude. -pub fn boardslide_control( - position: V, - point: V, - direction: V, - normal: V, - velocity: V, - translation: f32, - total_mass: f32, - exiting: bool, - ledge: bool, -) -> Vec { - let across = cross(direction, normal); - let offset = dot3(sub(position, point), across); - let inward = scale(across, if offset > 0.0 { -1.0 } else { 1.0 }); - if exiting { - return if offset.abs() > 0.12 { - vec![scale(inward, 201.0)] - } else { - Vec::new() - }; - } - let perpendicular = sub(velocity, scale(direction, dot3(velocity, direction))); - if ledge { - return vec![scale(across, translation * 25.0)]; - } - if offset.abs() > 0.16 { - let mut forces = vec![scale(inward, 10.0)]; - if dot3(inward, perpendicular) < 0.0 { - let mut stop = scale(perpendicular, -(total_mass * 60.0)); - stop[1] = 0.0; - forces.push(stop); - } - forces - } else { - let mut damping = scale(perpendicular, -20.0); - damping[1] = 0.0; - vec![scale(across, translation * 25.0), damping] - } -} - -///82D73AB0 straight, static 50-50 branch: both support normals coincide, -///primitive motion336 is zero. The manager age controls the native exit nudge. -pub fn fifty_fifty_pop( - velocity: V, - normal: V, - direction: V, - board_position: V, - point: V, - height: f32, - nudge: f32, - manager_age: f32, -) -> V { - let across = cross(direction, normal); - let balance = if manager_age < 0.31 { - if nudge < 0.0 { - nudge.min(-0.42) - } else if nudge > 0.0 { - nudge.max(0.42) - } else if dot3(sub(board_position, point), across) > 0.0 { - 0.42 - } else { - -0.42 - } - } else { - nudge - }; - let lateral = scale(across, balance * 1.8); - let length = dot3(lateral, lateral).sqrt(); - let lateral = if length > 1.8 { - scale(lateral, 1.8 / length) - } else { - lateral - }; +///82D3FD88 skips the accumulator call at or below its planar-speed threshold. +pub fn friction_applies(velocity: V, normal: V) -> bool { let planar = sub(velocity, scale(normal, dot3(velocity, normal))); - core::array::from_fn(|i| normal[i].mul_add(height * 1.03, planar[i]) + lateral[i]) -} -fn cross(a: V, b: V) -> V { - [ - (-a[2]).mul_add(b[1], a[1] * b[2]), - (-a[0]).mul_add(b[2], a[2] * b[0]), - (-a[1]).mul_add(b[0], a[0] * b[1]), - 0.0, - ] + length(planar) > 0.001 } -/// Grind PreUpdate82D3F4E8 / exit decision82D40DA0. Counts are native -/// simulation updates, not render frames. Once leaving, the phase is latched. -#[derive(Default)] -pub struct Release { - updates: u32, - leaving_updates: u32, - pub leaving: bool, +fn length(v: V) -> f32 { + let square = dot3(v, v); + if square == 0.0 { return 0.0; } + square * super::board_motion_output::inverse_length_squared(square, 2) } -impl Release { - pub fn begin_update(&mut self, speed: f32, explicit_exit: bool) { - self.leaving |= explicit_exit || (self.updates > 60 && speed < 0.15); + +fn reciprocal(value: f32) -> f32 { + let mut inverse = value.recip(); + for _ in 0..2 { + let correction = (-inverse).mul_add(value, 1.0); + inverse = inverse.mul_add(correction, inverse); } - pub fn finish_update(&mut self) -> bool { - self.leaving_updates = if self.leaving { self.leaving_updates.wrapping_add(1) } else { 0 }; - self.updates = self.updates.wrapping_add(1); - self.leaving_updates > 35 + inverse +} + + +///82D3FD88's material switch. S3 adds material13 to S2's material2 case. +pub fn material_multiplier(material: u32) -> f32 { + match material { + 2 | 13 => 1.7, + 3 => 2.6, + 4 => 0.6, + 5 => 4.0, + 6 => 5.0, + _ => 1.3, } } -///82D3F850: push away from the contacted edge until the native lateral -///speed limit is reached. Ledge engagement may supply its outward direction. -pub fn release_force( - position: V, point: V, direction: V, normal: V, velocity: V, - surface_kind: u32, surface_side: V, force_across: bool, - strength: f32, speed_limit: f32, lift: f32, -) -> V { - let outward = if !force_across && surface_kind != 0 { - scale(surface_side, -1.0) - } else { - let across = cross(direction, normal); - scale(across, if dot3(across, sub(position, point)) > 0.0 { 1.0 } else { -1.0 }) - }; - if dot3(velocity, outward) >= speed_limit { return [0.0; 4]; } - let lateral = scale(outward, strength); - let upward = normal[1].max(0.0) * lift; - core::array::from_fn(|i| normal[i].mul_add(upward, lateral[i])) +///82D3FA18: gravity relief bypasses the authored PinVsSlope four-point graph. +pub fn pin_slope( + normal: V, upmost_normal: V, gravity_relief: f32, + pin_vs_slope: &crate::point_graph::PointGraph<4>, +) -> f32 { + if gravity_relief > 0.0 { 1.0 } else { pin_vs_slope.evaluate(dot3(normal, upmost_normal)) } } diff --git a/crates/skate-core/src/physics/grind_forces/launch.rs b/crates/skate-core/src/physics/grind_forces/launch.rs new file mode 100644 index 00000000..56b031b3 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/launch.rs @@ -0,0 +1,74 @@ +//! LaunchSkateboard82D73AB0; S2 named counterpart82DCB3A8. +//! S3 replaces tunables with literals; energy cost is0.6, not ZIP's0.2. +use super::{length, reciprocal, scale, sub, V}; +use crate::physics::{grind_contact::manager::Jumper, native_arithmetic::dot3}; + +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub velocity_400: V, + pub position_112: V, + /// Low-energy zero-balance side selection uses CURRENT1120, not cache96. + pub current_point_1120: V, + pub balance_2800: f32, + pub geometry_side_jump: f32, + pub vertical_jump: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct Output { + pub velocity: V, + /// Complete retained cache, to replace the SAME owner updated by82D739D8. + pub jumper: Jumper, + pub flags_2476_to_or: u32, +} + +pub fn launch(mut jumper: Jumper, input: Input) -> Output { + let geometry = jumper.geometry; + let mut normal = geometry.normal; + let candidate = if matches!(jumper.family, 2 | 4) { + let across = cross(normal, geometry.direction); + let outward = if dot3(across, sub(input.position_112, geometry.point)) > 0. { + across + } else { scale(across, -1.) }; + let amount = if jumper.family == 4 { -0.1093 } else { 0.291 }; + core::array::from_fn(|i| normal[i] + outward[i] * amount) + } else { + core::array::from_fn(|i| geometry.upmost[i].mul_add(0.5, normal[i] * 0.5)) + }; + let magnitude = length(candidate); + if magnitude > 0.1 { normal = scale(candidate, reciprocal(magnitude)); } + let planar = sub(input.velocity_400, scale(normal, dot3(input.velocity_400, normal))); + let vertical = input.vertical_jump * 1.03; + let base: V = core::array::from_fn(|i| normal[i].mul_add(vertical, planar[i])); + let across = cross(geometry.direction, normal); + let mut balance = input.balance_2800; + let low_energy = jumper.energy < 0.31; + if low_energy { + if balance < 0. { + if balance - -0.42 >= 0. { balance = -0.42; } + } else if balance > 0. { + if !(balance - 0.42 >= 0.) { balance = 0.42; } + } else { + balance = if dot3(sub(input.position_112, input.current_point_1120), across) > 0. { + 0.42 + } else { -0.42 }; + } + jumper.cooldown = 10; + } + let side = if geometry.geometry_kind != 0 { input.geometry_side_jump } else { 0. }; + let mut lateral = sub(scale(across, balance * 1.8), scale(geometry.high_side, side)); + let magnitude = length(lateral); + if magnitude > 1.8 { lateral = scale(lateral, 1.8 / magnitude); } + let velocity = core::array::from_fn(|i| base[i] + lateral[i]); + jumper.launched = true; + let energy = jumper.energy - 0.6; + jumper.energy = if -energy >= 0. { 0. } else { energy }; + Output { velocity, jumper, flags_2476_to_or: if low_energy { 0x0200_0000 } else { 0 } } +} + +fn cross(a: V, b: V) -> V { + [(-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3])] +} diff --git a/crates/skate-core/src/physics/grind_forces/noise.rs b/crates/skate-core/src/physics/grind_forces/noise.rs new file mode 100644 index 00000000..6e83358d --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/noise.rs @@ -0,0 +1,43 @@ +//! Original orientation noise82D40580 / S2 AddNoiseToOrientation82D8FE78. +//! No replacement RNG: caller supplies the three consecutive board-stream u32 +//! draws, even for zero amount/speed. S3 hardcodes S2's full-speed/scalar tunables. +use super::V; + +pub fn angles(speed: f32, amount: f32, samples: [u32; 3]) -> [f32; 3] { + let speed_factor = (speed * 0.5).min(1.0); + let amplitude = speed_factor * amount * 0.06; + let width = amplitude * 2.0; + samples.map(|sample| { + let unit = (sample % 100_000) as f32 * 0.00001; + unit.mul_add(width, -amplitude) + }) +} + +pub fn apply(frame: [V; 4], speed: f32, amount: f32, samples: [u32; 3]) -> [V; 4] { + let [x, y, z] = angles(speed, amount, samples); + let (sx, cx) = crate::trigonometry::sin_cos(x); + let (sy, cy) = crate::trigonometry::sin_cos(y); + let (sz, cz) = crate::trigonometry::sin_cos(z); + // Raw standard-VMX words82D407F0=10CC526E and82D407F4=118C4AAE + // disambiguate IDA's misleading printed multiply/add operand order. + let sx_cz = sx * cz; + let cx_sz = cx * sz; + let cx_cz = cx * cz; + let sx_sz = sx * sz; + let columns = [ + [cy * cz, cy * sz, -sy], + [sy * sx_cz - cx_sz, sy.mul_add(sx_sz, cx_cz), cy * sx], + [sy.mul_add(cx_cz, sx_sz), sy * cx_sz - sx_cz, cy * cx], + ]; + let mut result = frame; + for column in 0..3 { + let v = columns[column]; + result[column] = core::array::from_fn(|lane| { + v[2].mul_add(frame[2][lane], + v[1].mul_add(frame[1][lane], v[0] * frame[0][lane])) + }); + } + //82D407EC/40810/40828 multiply the three basis vectors by zero + //and add the original translation. No positional jitter is introduced. + result +} diff --git a/crates/skate-core/src/physics/grind_forces/orientation.rs b/crates/skate-core/src/physics/grind_forces/orientation.rs new file mode 100644 index 00000000..856102a5 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/orientation.rs @@ -0,0 +1,104 @@ +//! ZIP orientation formulas checked against original S3 family bodies. +//! S2 equivalents82D9B818/918/BAC0/BCF8/BD88 retain tunable angles/blends; +//! S3 uses the literals below and adds darkslide's inverted normal. +use super::{length, reciprocal, scale, sub, V}; +use crate::physics::{native_arithmetic::dot3, board_motion_output::inverse_length_squared}; +pub type Frame = [V; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// Exact Processed64..127, not an effective/fakie-adjusted board frame. + pub board: Frame, + pub direction: V, + pub normal: V, + pub point: V, + pub yaw_1504: f32, + pub pitch_1508: f32, + pub switched: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct Target { pub frame: Frame, pub noise_amount: f32 } + +pub fn target(family: u32, i: Input) -> Result { + let frame = match family { + 0 if i.pitch_1508.abs() <= 0.12 => { + let forward = signed(i.direction, dot3(i.direction, i.board[2]) > 0.); + [cross(i.normal, forward), i.normal, forward, i.board[3]] + } + 0 | 3 => truck(i), + 1 | 5 => { + let right = signed(i.direction, dot3(i.direction, i.board[0]) > 0.); + let up = if family == 5 { scale(i.normal, -1.) } else { i.normal }; + [right, up, cross(right, up), i.board[3]] + } + 2 | 4 => { + let across = cross(i.direction, i.normal); + let axis = signed(i.direction, !(dot3(across, sub(i.board[3], i.point)) > 0.)); + // Original822F8618/822F8614,20/42degrees expressed in radians. + let angle = f32::from_bits(if family == 4 { 0x3f3b_a866 } else { 0x3eb2_b8c3 }); + let up = rotate(axis, i.normal, angle); + let right = signed(i.direction, dot3(i.direction, i.board[0]) > 0.); + [right, up, cross(right, up), i.board[3]] + } + _ => return Err("Unknown physical grind family"), + }; + Ok(Target { frame, noise_amount: if family == 0 { 0. } else { 0.09 } }) +} + +///82D40290: zero angles skip rotation; near-parallel support returns the entire +///input frame. Neither branch introduces a replacement normal or heading. +fn truck(i: Input) -> Frame { + let yaw = i.yaw_1504 * -0.68; + let mut forward = i.board[2]; + if yaw.abs() > 0. { forward = rotate(i.normal, forward, yaw); } + let right = cross(i.normal, forward); + let magnitude = length(right); + if magnitude <= 0.001 { return i.board; } + let right = scale(right, reciprocal(magnitude)); + let mut up = cross(forward, right); + let pitch = (if i.switched { -i.pitch_1508 } else { i.pitch_1508 }) * 0.68; + if pitch.abs() > 0. { + forward = rotate(right, forward, pitch); + up = cross(forward, right); + } + [right, up, forward, i.board[3]] +} + +///82D40890: reverse-order classical Gram-Schmidt on BOTH frames, then +///CURRENT's original82BD3150 helper. The temporary orthonormal frames' fourth +///vectors are explicitly zeroed (82D40930/82D409C8), not a position teleport. +pub fn blend(current: Frame, target: Frame) -> Frame { + crate::animation::foot_ik::interpolate_native( + &orthonormal(current), &orthonormal(target), 0.1, + ).0 +} + +fn orthonormal(input: Frame) -> Frame { + let mut output = [[0.; 4]; 4]; + for axis in (0..3).rev() { + let mut value = input[axis]; + for previous in ((axis + 1)..3).rev() { + value = sub(value, scale(output[previous], dot3(output[previous], input[axis]))); + } + output[axis] = scale(value, inverse_length_squared(dot3(value, value), 2)); + } + output +} + +fn rotate(axis: V, value: V, angle: f32) -> V { + let (sine, cosine) = crate::trigonometry::sin_cos(angle * 0.5); + let q = scale(axis, sine); + let first = cross(q, value); + let inner = core::array::from_fn(|i| cosine.mul_add(value[i], first[i])); + let second = cross(q, inner); + core::array::from_fn(|i| 2.0f32.mul_add(second[i], value[i])) +} + +fn signed(v: V, positive: bool) -> V { if positive { v } else { scale(v, -1.) } } +fn cross(a: V, b: V) -> V { + [(-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3])] +} diff --git a/crates/skate-core/src/physics/grind_forces/post.rs b/crates/skate-core/src/physics/grind_forces/post.rs new file mode 100644 index 00000000..7565926c --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/post.rs @@ -0,0 +1,51 @@ +//! Grind Post82D43098 -> Wipeout82D90898; S2 82D85E78 ->82DDF248. +//! Unlike Ground, no cooldown/vehicle/squash/balance checks are added here. +use crate::{ + physics::{board_motion_output::inverse_length_squared, native_arithmetic::dot3}, + player::wipeout::{Frame, Requests, regional_force}, + trigonometry, +}; + +/// Required physics_wipeout/default layout values. No borrowed Ground/Air +/// thresholds and no default-value fallback. Shared stock wiring supplies these. +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub max_arm_contact_164: f32, + pub max_body_contact_168: f32, + pub xz_acceleration_204: f32, + pub max_displacement_208: f32, + pub max_angular_deck_error_212: f32, +} + +pub fn check(state: &mut Requests, settings: Settings, frame: &Frame) { + state.mode = 5; // Preserve balance, timers and existing requests. + if dot3(frame.pose_error, frame.pose_error) + > settings.max_displacement_208 * settings.max_displacement_208 + { + state.request(1, 0.0); + } else if regional_force(frame, settings.max_body_contact_168, settings.max_arm_contact_164) { + state.request(0, 0.0); + } + //82D90AB8. Closing-velocity checks still run after displacement/contact. + if frame.board_contact { + let v = frame.closing_velocity; + let m = frame.world_to_animation; + let local: [f32; 4] = core::array::from_fn(|i| { + m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i] * v[0])) + }); + let square = dot3([local[0], 0.0, local[2], local[3]], + [local[0], 0.0, local[2], local[3]]); + let length = if square == 0.0 { 0.0 } else { square * inverse_length_squared(square, 2) }; + if length > settings.xz_acceleration_204 || local[1].abs() > 100.0 { + state.request(2, 0.0); + if regional_force(frame, 1.0, 20.0) { state.request(0, 0.0); } + } + } + //82D909B0 always checks all three corresponding deck/Input axes. + let cosine = dot3(frame.deck[0], frame.input_board[0]) + .min(dot3(frame.deck[1], frame.input_board[1])) + .min(dot3(frame.deck[2], frame.input_board[2])); + if trigonometry::acos(cosine.max(-1.0).min(1.0)) > settings.max_angular_deck_error_212 { + state.request(3, 0.0); + } +} diff --git a/crates/skate-core/src/physics/grind_forces/reckoning.rs b/crates/skate-core/src/physics/grind_forces/reckoning.rs new file mode 100644 index 00000000..f945b5a5 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/reckoning.rs @@ -0,0 +1,52 @@ +//! UpdateGrindStates82D8E3E0, compared with S2 named82DDD2A8. +//! Maintains CURRENT's shared filters/frames and BodySpin history; no second +//! reckoning owner and no ground/air-state substitute. +use super::{V, length}; +use crate::{air::{body_spin::{self, BodySpinState}, reckoning::AirState}, math::Vector3, + physics::{board_motion_output::inverse_length_squared, native_arithmetic::dot3}, + point_graph::PointGraph, + riding::{ground_orientation::GroundOrientation, reckoning_frames::ReckoningFrames}}; + +pub struct Settings { + pub ground_normal_smoothing: V, + /// Stock schema: layout800 TiltVsRotGround,672 TiltVsSlopeGround. + pub tilt_vs_rotation: PointGraph<8>, + pub tilt_vs_slope: PointGraph<8>, +} + +pub fn update(orientation: &mut GroundOrientation, frames: &mut ReckoningFrames, + body_spin: &mut BodySpinState, air: &mut AirState, settings: &Settings, + normal: V, heading: V, smoothing: f32, reverse_stance: bool, + physical_body_spin: f32) { + let old_up = lanes(orientation.up); + orientation.dynamic_up = orientation.up; + orientation.up_velocity = Vector3::ZERO; + frames.target_lean_angle = 0.; + air.secondary_lean_angle = 0.; + orientation.ground_blend = 0.; + let ground = orientation.ground_filter.update(settings.ground_normal_smoothing, normal); + orientation.ground_normal = xyz(ground); + frames.heading = heading; + let candidate: V = core::array::from_fn(|i| old_up[i].mul_add(smoothing, normal[i] * (1. - smoothing))); + // Native NormalizeSafe threshold is the original830BD350 splat1e-6; + // zero/short candidates retain the preceding up, not world-up. + let up = if length(candidate) > f32::from_bits(0x3586_37bd) { + let inverse = inverse_length_squared(dot3(candidate, candidate), 2); + candidate.map(|v| v * inverse) + } else { old_up }; + orientation.up = xyz(up); + orientation.target = orientation.up; + orientation.slow_filter.filter(up); + orientation.fast_filter.filter(up); + orientation.slow_filter.publish_current(up); + orientation.fast_filter.publish_current(up); + frames.calculate_transform(up, ground); + frames.calculate_tilt(reverse_stance, &settings.tilt_vs_rotation, &settings.tilt_vs_slope); + // Caller82D3F4E8 passes updateBodySpin=false, hence0. Other native callers + // may supply their actual PhysBodySpin; this is still the ground branch. + body_spin::update_ground(body_spin, physical_body_spin); + // No ResetSpin: Air's spin_angle/spin_speed survive this function. +} + +fn xyz(v: V) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +fn lanes(v: Vector3) -> V { [v.x, v.y, v.z, 0.] } diff --git a/crates/skate-core/src/physics/grind_forces/release.rs b/crates/skate-core/src/physics/grind_forces/release.rs new file mode 100644 index 00000000..516fc7b0 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/release.rs @@ -0,0 +1,33 @@ +//! Exit nudge82D3F850 / S2 82D85330. Family dispatch remains separate. +use super::{V, dot3, scale, sub}; + +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub position: V, + pub point: V, + pub across: V, + pub normal: V, + pub velocity: V, + pub geometry_kind: u32, + pub high_side: V, + pub force_across: bool, + /// Original r7. It is independent of r8/force_across; slides suppress lift. + pub enable_lift: bool, + pub strength: f32, + pub speed_limit: f32, + pub lift: f32, +} + +/// None means no AddForce call, including no accumulator wakeup. +pub fn force(input: Input) -> Option { + let outward = if input.force_across || input.geometry_kind == 0 { + scale(input.across, if dot3(input.across, sub(input.position, input.point)) > 0.0 { + 1.0 + } else { -1.0 }) + } else { scale(input.high_side, -1.0) }; + if !(dot3(input.velocity, outward) < input.speed_limit) { return None; } + let lateral = scale(outward, input.strength); + if !input.enable_lift { return Some(lateral); } + let upward = input.normal[1].max(0.0) * input.lift; + Some(core::array::from_fn(|i| input.normal[i].mul_add(upward, lateral[i]))) +} diff --git a/crates/skate-core/src/physics/grind_forces/slide.rs b/crates/skate-core/src/physics/grind_forces/slide.rs new file mode 100644 index 00000000..3191b976 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/slide.rs @@ -0,0 +1,57 @@ +//! Translation branches82D419A0/82D41438; S2 boardslide82D90970. +use super::{V, dot3, scale, sub}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Slide { Boardslide, Darkslide } + +/// All vectors are the physical-state/processed observations. Preparing-jump +/// is the retained state byte241, not a newly sampled analog input. +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub position: V, + pub point: V, + pub direction: V, + pub across: V, + pub velocity: V, + pub translation_2796: f32, + pub total_mass_2660: f32, + /// Source global update frequency, not an invented fixed60 or render rate. + pub update_frequency: f32, + pub preparing_jump: bool, + pub geometry_kind: u32, +} + +/// Forces at the processed deck position, in original application order. +/// The caller applies these directly, not through the21-entry tagged queue. +pub fn control(slide: Slide, input: Input) -> Vec { + let Input { position, point, direction, across, velocity, + translation_2796: translation, total_mass_2660: mass, + update_frequency, preparing_jump, geometry_kind } = input; + let offset = dot3(sub(position, point), across); + let inward = scale(across, if offset > 0.0 { -1.0 } else { 1.0 }); + if preparing_jump { + return if offset.abs() > 0.12 { vec![scale(inward, 201.0)] } else { Vec::new() }; + } + let translation_strength = match slide { + Slide::Boardslide => 25.0, + Slide::Darkslide => (1.0 - offset.abs() * 6.25) * 40.0, + }; + let translate = scale(across, translation * translation_strength); + if geometry_kind == 2 { + return vec![translate]; + } + let perpendicular = sub(velocity, scale(direction, dot3(velocity, direction))); + if offset.abs() > 0.16 { + let mut forces = vec![scale(inward, 10.0)]; + if dot3(inward, perpendicular) < 0.0 { + let mut stop = scale(perpendicular, -(mass * update_frequency)); + stop[1] = 0.0; + forces.push(stop); + } + forces + } else { + let mut damping = scale(perpendicular, -20.0); + damping[1] = 0.0; + vec![translate, damping] + } +} diff --git a/crates/skate-core/src/physics/grind_forces/support.rs b/crates/skate-core/src/physics/grind_forces/support.rs new file mode 100644 index 00000000..eb4a0684 --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/support.rs @@ -0,0 +1,24 @@ +//! Common force leaves82D40080/82D3FFC0/82D40130/82D401F8. +//! These consume manager output; they do not duplicate its assistance update. +use super::{scale, V}; +use crate::{physics::native_arithmetic::dot3, point_graph::PointGraph}; + +pub fn pin(normal: V, gravity_relief: f32) -> Option { + if !(gravity_relief > 0.) { return None; } + let mut force = scale(normal, -40.); + force[1] = 0.; + Some(force) +} + +pub fn coping(upmost: V, velocity: V, gravity_relief: f32) -> Option { + (gravity_relief > 0. && dot3(upmost, velocity) < 0.).then_some([0., 120., 0., 0.]) +} + +pub fn exit_lean(high_side: V, angle_1500: f32, graph: &PointGraph<4>) -> Option { + (angle_1500 > 0.).then(|| scale(high_side, graph.evaluate(angle_1500) * -65.)) +} + +///Unlike conditional pin/coping, original always calls AddForce, even at0. +pub fn truck_compensation(normal: V, pitch_1508: f32) -> V { + scale(normal, pitch_1508.abs() * -600.) +} diff --git a/crates/skate-core/src/physics/grind_forces/tests.rs b/crates/skate-core/src/physics/grind_forces/tests.rs new file mode 100644 index 00000000..33424eef --- /dev/null +++ b/crates/skate-core/src/physics/grind_forces/tests.rs @@ -0,0 +1,82 @@ +use super::*; +const X: V = [1.0, 0.0, 0.0, 0.0]; +const Y: V = [0.0, 1.0, 0.0, 0.0]; +const Z: V = [0.0, 0.0, 1.0, 0.0]; +const O: V = [0.0; 4]; +fn near(a: f32, b: f32) { assert!((a - b).abs() < 0.0001, "{a} != {b}"); } + +#[test] +fn material_and_geometry_are_independent_and_support_is_not_the_grind_normal() { + assert_eq!(material_multiplier(13), material_multiplier(2)); + assert_eq!(material_multiplier(999), 1.3); + for (kind, strength) in [(0, 50.0), (1, 40.0), (2, 30.0), (99, 30.0)] { + let force = friction([4.0, 9.0, 0.0, 0.0], Y, [0.0, 0.25, 0.0, 0.0], + 0.5, true, material_multiplier(4), kind, [50.0, 40.0, 30.0]); + near(force[0], -0.25 * 0.5 * 1.9 * 0.6 * strength * 0.48); + near(force[1], 0.0); + } + assert!(!friction_applies([0.001, 10.0, 0.0, 0.0], Y)); + assert!(friction_applies([0.002, 10.0, 0.0, 0.0], Y)); +} + +#[test] +fn pin_uses_signed_reference_and_skips_zero_error_instead_of_waking_body() { + assert_eq!(lateral_pin([X, Y, Z, O], O, X, X, 800.0, 0.0, 0.0, true, 1.0), None); + let force = lateral_pin([X, Y, Z, O], X, X, [2.0, 0.0, 0.0, 0.0], + 100.0, 0.0, 0.0, true, 0.5).unwrap(); + near(force[0], (100.0 - 100.0 * 0.133 * 2.0) * 0.5); + let graph = crate::point_graph::PointGraph { x: [-1.0, 0.0, 0.5, 1.0], y: [0.0, 0.0, 0.5, 0.75] }; + assert_eq!(pin_slope(Y, Y, 0.0, &graph), 0.75); + assert_eq!(pin_slope(Y, Y, 0.01, &graph), 1.0); +} + +fn slide_input() -> slide::Input { + slide::Input { position: O, point: O, direction: Z, across: X, + velocity: [2.0, 3.0, 4.0, 0.0], translation_2796: 0.5, + total_mass_2660: 10.0, update_frequency: 30.0, + preparing_jump: false, geometry_kind: 0 } +} + +#[test] +fn slide_force_order_mass_frequency_and_darkslide_translation() { + let mut input = slide_input(); + let f = slide::control(slide::Slide::Boardslide, input); + assert_eq!(f, vec![[12.5, 0.0, 0.0, 0.0], [-40.0, 0.0, 0.0, 0.0]]); + input.position[0] = 0.08; + let f = slide::control(slide::Slide::Darkslide, input); + near(f[0][0], 10.0); //(.5 remaining)*40*.5 translation. + input.position[0] = 0.2; + let f = slide::control(slide::Slide::Boardslide, input); + near(f[0][0], -10.0); + near(f[1][0], -600.0); //Actual input frequency30, not ZIP fixed60. + near(f[1][1], 0.0); + input.preparing_jump = true; + input.position[0] = 0.12; + assert!(slide::control(slide::Slide::Boardslide, input).is_empty()); +} + +#[test] +fn exit_lift_flag_is_independent_and_speed_equality_skips_force() { + let mut input = release::Input { position: X, point: O, across: X, normal: Y, + velocity: O, geometry_kind: 2, high_side: Z, force_across: false, + enable_lift: false, strength: 90.0, speed_limit: 0.75, lift: 125.0 }; + assert_eq!(release::force(input), Some([0.0, 0.0, -90.0, 0.0])); + input.enable_lift = true; + assert_eq!(release::force(input), Some([0.0, 125.0, -90.0, 0.0])); + input.force_across = true; + input.velocity = [0.75, 0.0, 0.0, 0.0]; + assert_eq!(release::force(input), None); +} + +#[test] +fn noise_preserves_draw_order_modulo_speed_cap_and_position() { + let a = noise::angles(2.0, 1.0, [0, 50_000, 99_999]); + near(a[0], -0.06); near(a[1], 0.0); near(a[2], 0.0599988); + assert_eq!(a, noise::angles(20.0, 1.0, [100_000, 150_000, 199_999])); + let frame = [X, Y, Z, [8.0, 9.0, 10.0, 1.0]]; + let n = noise::apply(frame, 2.0, 1.0, [12_345, 54_321, 87_654]); + assert_eq!(n[3], frame[3]); + for column in &n[..3] { near(dot3(*column, *column), 1.0); } + near(dot3(n[0], n[1]), 0.0); + assert_eq!(noise::apply(frame, 0.0, 1.0, [0, 1, 2]), frame); +} diff --git a/crates/skate-core/src/physics/manual/controller.rs b/crates/skate-core/src/physics/manual/controller.rs index 04b96d53..3d4c5ca3 100644 --- a/crates/skate-core/src/physics/manual/controller.rs +++ b/crates/skate-core/src/physics/manual/controller.rs @@ -32,11 +32,14 @@ pub struct ManualInput { pub reference_z: [f32; 4], /// Deck part6 transform's z-axis from 82585CB0, not an animation pose. pub deck_z: [f32; 4], - /// Processed transform third column+224 and angular velocity+416. + /// Processed effective transform third column+224 and ground velocity+416. pub velocity_frame_z: [f32; 4], + /// Legacy field name: this is Processed416, NOT angular velocity720. + /// Native82C04C88 projects it into the effective frame;82C04E60..EC4 + /// emits -400 times this same translational velocity for correction. pub angular_velocity_world: [f32; 4], - /// Native body caches. Producers/original names remain unresolved; these - /// layout identities stay at the input boundary instead of guessed levers. + /// Front/back truck drive-frame translations (7840+48 and7904+48). + ///82C0B9C0 steering rotates the bases and preserves these translations. pub correction_point_7888: [f32; 4], pub correction_point_7952: [f32; 4], } @@ -171,8 +174,8 @@ fn apply_correction( measured: f32, ) { let frame = input.velocity_frame_z; - let omega = input.angular_velocity_world; - let local_z = frame[2].mul_add(omega[2], frame[1].mul_add(omega[1], frame[0] * omega[0])); + let velocity = input.angular_velocity_world; + let local_z = frame[2].mul_add(velocity[2], frame[1].mul_add(velocity[1], frame[0] * velocity[0])); if !input.braking || !(0.0 > local_z * input.balance) { return; } @@ -198,7 +201,7 @@ fn apply_correction( } else { input.correction_point_7952 }; - effect.corrective_force_world = omega.map(|component| (component * -1.0) * 400.0); + effect.corrective_force_world = velocity.map(|component| (component * -1.0) * 400.0); effect.correction_active = true; } diff --git a/crates/skate-core/src/physics/manual/settings.rs b/crates/skate-core/src/physics/manual/settings.rs index a733b7b2..cc3ddcce 100644 --- a/crates/skate-core/src/physics/manual/settings.rs +++ b/crates/skate-core/src/physics/manual/settings.rs @@ -34,8 +34,9 @@ pub struct ManualSettings { #[derive(Clone, Copy, Debug)] pub struct ManualMode { - /// physics_mode+32 Hash_84CB2F459F3FC811, compared to abs(local angular Z). - /// Original field name remains unresolved; this name describes its use. + /// physics_mode+32 Hash_84CB2F459F3FC811, compared to abs(local ground + /// velocity Z). Legacy Rust field name; the source reads Processed416, + /// not the angular velocity at720. Original stock name remains unresolved. pub correction_angular_speed_threshold: f32, /// physics_mode+36 Hash_F8CBC0F5FEF2240E, read as a byte. pub corrective_force_enabled: bool, diff --git a/crates/skate-core/src/physics/mass.rs b/crates/skate-core/src/physics/mass.rs index 5ae5c78a..ed07b9b3 100644 --- a/crates/skate-core/src/physics/mass.rs +++ b/crates/skate-core/src/physics/mass.rs @@ -84,7 +84,9 @@ fn inverse_mass_frame(forward: RetailLocalMassFrame, inertia: Vector3) -> Retail ); let minimum_offset_squared = ((inertia.y + inertia.z) + inertia.x) * f32::from_bits(0x3586_37BE); - let translated = center_squared >= minimum_offset_squared; + // S3 82AE78EC / S2 82AE9BE0: bge after fcmpu means LT is clear, + // so an unordered comparison also retains the inverse mass frame. + let translated = !(center_squared < minimum_offset_squared); let rotated = forward .basis .columns @@ -219,10 +221,18 @@ fn finalize_principal_mass( refined_reciprocal(principal_moments.y), refined_reciprocal(principal_moments.z), ); - let smallest_inverse = native_arithmetic::vector_min( - native_arithmetic::vector_min(inverse_tensor.x, inverse_tensor.y), - inverse_tensor.z, - ); + // S3 82AE7A98/7AAC and S2 82AE9DA0/9DB4 retain the left operand + // only for ordered LT. Rust min would discard a right-hand NaN. + let smallest_xy = if inverse_tensor.x < inverse_tensor.y { + inverse_tensor.x + } else { + inverse_tensor.y + }; + let smallest_inverse = if smallest_xy < inverse_tensor.z { + smallest_xy + } else { + inverse_tensor.z + }; RetailBodyMassProperties { local_mass_frame, dynamics: RetailInertiaDynamics { diff --git a/crates/skate-core/src/physics/mod.rs b/crates/skate-core/src/physics/mod.rs index 3d3cab68..6802e81d 100644 --- a/crates/skate-core/src/physics/mod.rs +++ b/crates/skate-core/src/physics/mod.rs @@ -3,8 +3,8 @@ pub mod assembly; pub mod board; pub mod board_animation; -pub mod board_dynamic_normal; pub mod board_ground; +pub mod board_dynamic_normal; pub mod board_input_output; pub mod board_motion_output; pub mod board_pose; @@ -12,11 +12,14 @@ pub mod board_runtime; pub mod board_step; pub mod board_toolkit; pub mod board_world; -pub mod centre_of_mass_filter; pub mod collision; +pub mod centre_of_mass_filter; pub mod constraint_batch; mod constraint_frames; pub mod contact; +pub mod grind_contact; +pub mod grind_air; +pub mod grind_forces; pub mod contact_feedback; mod contact_layout; pub mod contact_solver; @@ -24,8 +27,8 @@ pub mod drive_frames; pub mod drive_parameters; pub mod drive_preparation; pub mod drive_solver; -pub mod foot_physical_output; pub mod force_queue; +pub mod foot_physical_output; pub mod hook_drive; pub mod joint_builder; pub mod joint_records; @@ -41,8 +44,6 @@ pub mod solver; pub mod startup; mod triangle_closest; pub mod triangle_query; -pub mod grind_contact; -pub mod grind_forces; mod triangle_sweep; mod triangle_sweep_roots; mod triangle_sweep_walk; @@ -60,17 +61,16 @@ pub mod ground_hang_geometry; #[cfg(test)] #[path = "tests/primitive_query.rs"] mod primitive_query_tests; -pub mod skateboard_controller; -pub mod skeleton_air_frames; pub mod skeleton_animation_record; -pub mod skeleton_biped_air; +pub mod skeleton_air_frames; pub mod skeleton_board_frames; +pub mod skeleton_output; pub mod skeleton_board_offset; pub mod skeleton_body; -pub mod skeleton_general; pub mod skeleton_landing; pub mod skeleton_motion; -pub mod skeleton_output; pub mod skeleton_root; - -pub mod grind_air; +pub mod skeleton_general; +pub mod skeleton_biped_air; +pub mod skeleton_landing_on_board; +pub mod skateboard_controller; diff --git a/crates/skate-core/src/physics/rigid_body.rs b/crates/skate-core/src/physics/rigid_body.rs index 0be86006..39cb4211 100644 --- a/crates/skate-core/src/physics/rigid_body.rs +++ b/crates/skate-core/src/physics/rigid_body.rs @@ -133,8 +133,9 @@ pub struct RetailBodyRateStep { /// - `mIfull = (Ixx, Ixy, Ixz)`; /// - `mIsplt = (Izz, Iyy, Iyz)`. /// -/// This retained lane mapping requires independent TU3 instruction review. -/// Earlier generated-code validation has been withdrawn. +/// Independently traced through original S3 DynamicUpdate82AE6778..6804 and +/// S2 DynamicUpdate82AE467C..46FC. This verifies the six-component layout, +/// not bit-exact Xenon arithmetic; generated-code validation is withdrawn. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RetailPackedWorldInverseInertia { pub full: Vector3, @@ -278,7 +279,8 @@ pub fn integrate_body_rates( } } -/// Quaternion branch from TU3 `0x82AE66A8..0x82AE67B8`. +/// Quaternion branch from TU3 `0x82AE6668..0x82AE66C8`, paired with +/// Skate 2 `0x82AE455C..0x82AE45C8`. /// /// Mapping the VMX128 word permutations back to RenderWare's public /// `(x,y,z,w)` lane order gives: diff --git a/crates/skate-core/src/physics/rigid_body/dynamic_update.rs b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs index ea4a23ef..5b083008 100644 --- a/crates/skate-core/src/physics/rigid_body/dynamic_update.rs +++ b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs @@ -60,6 +60,8 @@ pub fn dynamic_update_packed( // rather than recalculating squared speed from the rounded scaled vector. let factor = f32::from_bits(inertia[5]) * f32::from_bits(body[31]); let energy = factor.mul_add(angular_squared, linear_squared); + // S3 82AE6974 / S2 82AE487C: bge tests LT clear, including unordered. + // Do not replace this with >=; the host predicate below is intentional. body[43] = if !(energy < simulation.minimum_energy) { 0 } else { @@ -85,7 +87,7 @@ pub fn dynamic_update_packed( } pub(super) fn orientation(q: V, rotation: V) -> V { - // 82AE6680..67B8: preserve the native cross-product and normalization order. + // S3 82AE6668..66C8 / S2 82AE455C..45C8: cross product then normalization. let cross = perm( nmsub( perm(rotation, [1, 2, 0, 3]), @@ -135,6 +137,8 @@ pub(super) fn inverse_inertia(basis: [V; 3], tensor: [u32; 3]) -> (V, V) { fn cap(value: V, maximum: f32) -> (V, f32) { let squared = dot(value, value, false); let maximum_squared = maximum * maximum; + // S3 82AE6888/68FC and S2 82AE478C/4804 skip on GT clear (ble), + // including unordered. Thus ordered >, not !(<=), enters the cap path. if squared > maximum_squared { let ratio = maximum_squared / squared; let root = ratio * refined_rsqrt(ratio); diff --git a/crates/skate-core/src/physics/skateboard_controller.rs b/crates/skate-core/src/physics/skateboard_controller.rs index fbbd790c..a76d3433 100644 --- a/crates/skate-core/src/physics/skateboard_controller.rs +++ b/crates/skate-core/src/physics/skateboard_controller.rs @@ -33,7 +33,7 @@ impl SkateboardController { let previous = self.fields.state_448; self.fields.word_444 = 0; if previous != 0 { - actions.let_go_of_skateboard(&mut self.fields); + actions.let_go_of_skateboard(); self.fields.state_448 = 0; } self.fields.system_on_452 = false; diff --git a/crates/skate-core/src/physics/skeleton_biped_air.rs b/crates/skate-core/src/physics/skeleton_biped_air.rs index 2715fea4..46cdf2e7 100644 --- a/crates/skate-core/src/physics/skeleton_biped_air.rs +++ b/crates/skate-core/src/physics/skeleton_biped_air.rs @@ -1,32 +1,33 @@ -//! Skeleton BipedAir frame production82BDDD70..82BDDFC0. +//! Biped Skeleton82BDDD70: COM-constrained animation root, not a body teleport. +//! Original S3 SHA431b8eba...0395a; ordinary PC arithmetic, not Xenon parity. use super::{ skeleton_animation_record::{AnimationPartTransform as Frame, compose_affine}, skeleton_root::{SkeletonRootFrames, orthonormalize}, }; + +///82BDDDC8..DF54. The frame's translation is replaced by the trajectory COM +///minus the rotated LOCAL animation COM10960. Mapped board12624 is independent. pub fn prepare( roots: &mut SkeletonRootFrames, - frame: Frame, - body_position: [f32; 4], + mut frame: Frame, + trajectory_position: [f32; 4], local_com: [f32; 4], - mapped: &Frame, + mapped_board: &Frame, flags_2476: u32, flags_2468: &mut u32, ) -> Frame { - let mut frame = frame; + roots.initialize_heading = true; if flags_2476 & 4 != 0 { for axis in [0, 2] { - frame[axis] = frame[axis].map(|v| -v); + frame[axis] = frame[axis].map(|lane| -lane); } } - frame[3] = std::array::from_fn(|i| { - body_position[i] - - frame[2][i].mul_add( - local_com[2], - frame[1][i].mul_add(local_com[1], frame[0][i] * local_com[0]), - ) + frame[3] = core::array::from_fn(|lane| { + let x = frame[0][lane] * local_com[0]; + let y = frame[1][lane].mul_add(local_com[1], x); + trajectory_position[lane] - frame[2][lane].mul_add(local_com[2], y) }); - roots.initialize_heading = true; roots.reset_initial_alignment(orthonormalize(frame)); *flags_2468 |= 0x80000; - compose_affine(&roots.animation_to_world, mapped) + compose_affine(&roots.animation_to_world, mapped_board) } diff --git a/crates/skate-core/src/physics/skeleton_board_frames.rs b/crates/skate-core/src/physics/skeleton_board_frames.rs index b0a7dddf..141db27f 100644 --- a/crates/skate-core/src/physics/skeleton_board_frames.rs +++ b/crates/skate-core/src/physics/skeleton_board_frames.rs @@ -13,8 +13,6 @@ pub struct SkeletonBoardFrames { pub physical_board: Transform, pub skate_root: Transform, pub animation_target: Transform, - /// Skeleton16016: authored deck frame retained during off-board transitions. - pub retained_board_16016: Transform, pub com_frame: Transform, pub lifted_com_frame: Transform, pub centre_of_mass: [f32; 4], @@ -31,7 +29,6 @@ impl Default for SkeletonBoardFrames { physical_board: IDENTITY, skate_root: IDENTITY, animation_target: IDENTITY, - retained_board_16016: IDENTITY, com_frame: IDENTITY, lifted_com_frame: IDENTITY, centre_of_mass: [0.; 4], diff --git a/crates/skate-core/src/physics/skeleton_body/target_update.rs b/crates/skate-core/src/physics/skeleton_body/target_update.rs index 48cbf87b..288a27c7 100644 --- a/crates/skate-core/src/physics/skeleton_body/target_update.rs +++ b/crates/skate-core/src/physics/skeleton_body/target_update.rs @@ -45,18 +45,26 @@ pub struct SkeletonTargetUpdate { } impl SkeletonTargets { + ///82BE1618 updates only the two hook targets and returns the board-space + /// transform consumed by the skeleton input owner. + pub fn update_hook_positions(&mut self, input: &SkeletonTargetInput<'_>) -> Transform { + let hips = compose_affine(input.animation_to_world, input.animation_hips); + let board = compose_affine(input.animation_to_world, input.animation_board); + let animation_board_to_physics = compose_affine(input.inverse_board, &board); + self.set_transform(0, orthonormalize(hips)); + self.set_transform(1, orthonormalize(*input.skate_root)); + animation_board_to_physics + } + pub fn update_positions( &mut self, input: SkeletonTargetInput<'_>, skeleton: &mut SkeletonBody, ) -> SkeletonTargetUpdate { - let hips = compose_affine(input.animation_to_world, input.animation_hips); - let board = compose_affine(input.animation_to_world, input.animation_board); - let animation_board_to_physics = compose_affine(input.inverse_board, &board); let previous = self.transform(0)[3]; - let change = std::array::from_fn(|i| previous[i] - hips[3][i]); - self.set_transform(0, orthonormalize(hips)); - self.set_transform(1, orthonormalize(*input.skate_root)); + let animation_board_to_physics = self.update_hook_positions(&input); + let hips = self.transform(0)[3]; + let change = std::array::from_fn(|i| previous[i] - hips[i]); let positions = self.update_extra_targets(skeleton, input.com_frame, input.lifted_com_frame); //82BE18A0 checks strictly greater; unordered distance alone does not diff --git a/crates/skate-core/src/physics/skeleton_landing_on_board.rs b/crates/skate-core/src/physics/skeleton_landing_on_board.rs new file mode 100644 index 00000000..c8e3a0c7 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_landing_on_board.rs @@ -0,0 +1,72 @@ +//! Skeleton82BDDC68,82BE0858,82BE0CC0 and82BDCC28, original S3 TU3. +//! These calculate animation targets; the existing skeleton drives own physics. +use super::{ + skeleton_animation_record::{AnimationPartTransform as Frame, compose_affine, transform_point}, + skeleton_root::{SkeletonRootFrames, orthonormalize, inverse_rigid}, + board_motion_output::inverse_length_squared, +}; +use crate::{animation::foot_ik::interpolate_affine, point_graph::PointGraph, + player::wipeout_state::math::{cross,dot,scale,sub}}; +pub type Vector=[f32;4]; +const UP:Vector=[0.,1.,0.,0.]; +const EPSILON:f32=f32::from_bits(0x37800000); +#[derive(Clone,Copy)] +pub struct Settings { pub root_y_offset:f32, pub capsule_radius:f32, pub capsule_length:f32 } +///82BDDC68 changes only skate-root and invokes COM-lift on the OLD animation root. +pub fn skate_root(old:Frame, physical_com:Vector, flags2480:u32, ground_y:f32, + settings:Settings)->Frame { + let mut position=physical_com; + position[1]=old[3][1]+settings.root_y_offset; + if flags2480&1!=0 { + let requested=(ground_y+settings.capsule_length)+settings.capsule_radius; + position[1]=requested.clamp(position[1],position[1]+0.3); + } + [old[2],old[0],old[1],position] +} +///82BE0858. Packet trajectory reset is performed by the owning host before this. +pub fn update_root(roots:&mut SkeletonRootFrames, com:Vector, local_com:Vector, + spin:f32, revert_frames:Option, reversed:bool) { + roots.initialize_heading=true; + let angle=revert_frames.map_or(spin,|frames| { + let magnitude=std::f32::consts::PI/frames as f32; + if reversed {magnitude} else {-magnitude} + }); + let (sin,cos)=crate::trigonometry::sin_cos(angle); + let yaw=[[cos,0.,-sin,0.],[0.,1.,0.,0.],[sin,0.,cos,0.],[0.;4]]; + let mut frame=compose_affine(&roots.animation_to_world,&yaw); + if revert_frames.is_some() {frame=orthonormalize(frame);} + //82BE0CC0: world-up correction with two independent degeneracy tests. + let deviation=cross(frame[1],UP); + if dot(deviation,deviation)>EPSILON { + let right=cross(UP,frame[2]); + let sq=dot(right,right); + if sq>EPSILON { + let right=scale(right,inverse_length_squared(sq,2)); + let target=[right,UP,cross(right,UP),frame[3]]; + frame=interpolate_affine(&frame,&target,0.1); + } + } + let mut rotation=frame; + rotation[3]=[0.;4]; + frame[3]=sub(com,transform_point(&rotation,local_com)); + roots.reset_initial_alignment(orthonormalize(frame)); +} +///82BDCC28 returns no write outside its descending/unsuppressed branch. +pub fn pose_adjustment(world_to_animation:&Frame, actual_board:&Frame, mapped_board:&Frame, + ik_offset:Vector, com_velocity_y:f32, flags2480:u32, + time:f32, blend:&PointGraph<8>)->Option { + if !(com_velocity_y<0.) || flags2480&2!=0 {return None;} + let mut target=compose_affine(world_to_animation,actual_board); + let mut rotation=*world_to_animation; + rotation[3]=[0.;4]; + let mut correction=transform_point(&rotation,ik_offset); + correction[1]=0.; + if target[3][1]>mapped_board[3][1] { + correction[1]=target[3][1]-mapped_board[3][1]; + } + target[3]=std::array::from_fn(|lane|mapped_board[3][lane]+correction[lane]); + let blended=interpolate_affine(mapped_board,&target,blend.evaluate(time)); + let mut offset=compose_affine(&blended,&inverse_rigid(mapped_board)); + offset[3][1]=-offset[3][1]; + Some(offset) +} diff --git a/crates/skate-core/src/physics/tests/board_ground.rs b/crates/skate-core/src/physics/tests/board_ground.rs index f77e0e08..291a8191 100644 --- a/crates/skate-core/src/physics/tests/board_ground.rs +++ b/crates/skate-core/src/physics/tests/board_ground.rs @@ -1,5 +1,30 @@ use super::*; +/// Regression for the paired postphysics branch contract, not fidelity proof. +/// S2 82B372E0 names mAngularDrag; S3 82C08634 stores inertia+36. +#[test] +fn wheel_angular_drag_tracks_physical_contact_and_wipeout() { + let mut ground = BoardGroundState::default(); + let mut lines = WheelLineState::default(); + // A successful line query is not physical wheel contact for drag selection. + lines.publish([Some(WheelLineHit { fraction: 0.1, normal: UP, surface_tag: 0 }); 4]); + let frequency = f32::from_bits(0x426F_FFFF); + let free = f32::from_bits(0x3BC4_9BA6) * frequency; + let wiping_out = f32::from_bits(0x3D23_D70A) * frequency; + let contacts = [ + report(BodyId::RightFrontWheel, UP), + report(BodyId::LeftBackWheel, UP), + ]; + ground.update(&contacts, &lines, UP, 80.0, false); + assert_eq!(ground.wheel_angular_drag, [0.0, free, free, 0.0]); + ground.update(&contacts, &lines, UP, 80.0, true); + assert_eq!(ground.wheel_angular_drag, [wiping_out, free, free, wiping_out]); + ground.update(&[], &lines, UP, 80.0, true); + assert_eq!(ground.wheel_angular_drag, [free; 4]); + ground.update(&contacts, &lines, UP, 80.0, false); + assert_eq!(ground.wheel_angular_drag, [0.0, free, free, 0.0]); +} + fn report(part: BodyId, normal: Vector3) -> BoardContactReport { BoardContactReport { part, other: CollisionBody::StaticWorld, is_body_a: true, diff --git a/crates/skate-core/src/physics/tests/board_world.rs b/crates/skate-core/src/physics/tests/board_world.rs index 7c00b0de..b8ac4ea1 100644 --- a/crates/skate-core/src/physics/tests/board_world.rs +++ b/crates/skate-core/src/physics/tests/board_world.rs @@ -90,151 +90,6 @@ fn close(a: f32, b: f32) { assert!((a - b).abs() < 2e-6, "{a} != {b}"); } -fn clustered(triangles: Vec) -> BoardWorld { - use crate::physics::board_world::query_metadata::{ - Bounds, QueryMesh, QueryMetadata, QueryPool, - }; - let metadata = QueryMetadata { - packed_surfaces: vec![0; triangles.len()], - meshes: triangles - .iter() - .enumerate() - .map(|(i, t)| QueryMesh { - triangle_range: i..i + 1, - local_to_world: RetailAffineTransform::IDENTITY, - world_to_local: RetailAffineTransform::IDENTITY, - local_bounds: Bounds::from_points(t.triangle.vertices).unwrap(), - matching_group: -1, - pool: QueryPool::Ground, - }) - .collect(), - static_edges: vec![], - island_flags: 0, - }; - BoardWorld::with_query_metadata(triangles, metadata).unwrap() -} - -#[test] -fn cluster_culling_preserves_swept_hits_and_equal_hit_order() { - let mut rounded = triangle(); - rounded.triangle.fatness = 0.5; - let mut later = rounded; - later.tag = 999; - let indexed = clustered(vec![rounded, later]); - let full = BoardWorld::new(vec![rounded, later]); - for x in [-100., -10., -1., 0., 9., 100.] { - for radius in [0., 0.2, 1.] { - let start = Vector3::new(x, 2., 0.); - let end = Vector3::new(x, -2., 0.); - assert_eq!( - format!("{:?}", indexed.query_swept_line(start, end, radius)), - format!("{:?}", full.query_swept_line(start, end, radius)) - ); - } - } - assert_eq!( - indexed - .query_thin_line(Vector3::new(0., 2., 0.), Vector3::new(0., -2., 0.)) - .unwrap() - .unwrap() - .tag, - 42 - ); - assert_eq!( - indexed - .line_candidates(Vector3::new(100., 2., 0.), Vector3::new(100., -2., 0.), 0.) - .count(), - 0 - ); -} - -#[test] -fn cluster_culling_preserves_predictive_contacts_for_every_shape() { - let mut floor = triangle(); - floor.triangle.fatness = 0.1; - let mut indexed = clustered(vec![floor]); - let mut full = BoardWorld::new(vec![floor]); - let mut settings = config(); - settings.query.volume_padding = 0.05; - settings.query.maximum_separating_distance = 0.8; - for y in [0.1, 0.4, 0.9, 2.] { - let center = Vector3::new(0., y, 0.); - let basis = Basis3 { - columns: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], - }; - let mut moving_triangle = triangle().triangle; - for p in &mut moving_triangle.vertices { - p.y += y; - } - for primitive in [ - ContactPrimitive::Sphere(Sphere { - center, - radius: 0.2, - }), - ContactPrimitive::Capsule { - center, - axis: Vector3::new(1., 0., 0.), - half_length: 0.4, - radius: 0.2, - }, - ContactPrimitive::RoundedBox { - center, - basis, - half_extents: Vector3::new(0.4, 0.2, 0.3), - radius: 0.05, - }, - ContactPrimitive::Triangle(moving_triangle), - ] { - let volumes = [BoardWorldVolume { - body: CollisionBody::Board(BodyId::ORDER[0]), - primitive, - linear_velocity: Vector3::new(0., -60., 0.), - material: settings.material, - }]; - assert_eq!( - format!( - "{:?}", - indexed.query_primitives(&volumes, settings.query, settings.retention) - ), - format!( - "{:?}", - full.query_primitives(&volumes, settings.query, settings.retention) - ) - ); - } - } -} - -#[test] -fn triangle_bounds_match_unfiltered_contacts_near_edges_and_rotated_boxes() { - let mut floor = triangle(); - floor.triangle.fatness = 0.15; - let mut indexed = clustered(vec![floor]); - let mut full = BoardWorld::new(vec![floor]); - let mut settings = config(); - settings.query.volume_padding = 0.1; - settings.query.maximum_separating_distance = 0.8; - for x in [-10.5, -10., -5., 0., 5., 10., 10.5] { - for z in [-10.5, -10., 0., 10., 10.5] { - for y in [-0.1, 0.2, 1., 2.] { - let center = Vector3::new(x, y, z); - for primitive in [ - ContactPrimitive::Sphere(Sphere { center, radius: 0.2 }), - ContactPrimitive::Capsule { center, axis: Vector3::new(0.6, 0.8, 0.), half_length: 0.7, radius: 0.2 }, - ContactPrimitive::RoundedBox { center, - basis: Basis3 { columns: [[0.6, 0., 0.8], [0., 1., 0.], [-0.8, 0., 0.6]] }, - half_extents: Vector3::new(0.6, 0.2, 0.1), radius: 0.05 }, - ] { - let volumes = [BoardWorldVolume { body: CollisionBody::Board(BodyId::ORDER[0]), primitive, - linear_velocity: Vector3::new(20., -60., -30.), material: settings.material }]; - assert_eq!(format!("{:?}", indexed.query_primitives(&volumes, settings.query, settings.retention)), - format!("{:?}", full.query_primitives(&volumes, settings.query, settings.retention))); - } - } - } - } -} - #[test] fn finite_world_geometry_produces_four_ordered_wheel_contacts_with_combined_materials() { let mut world = BoardWorld::new(vec![triangle()]); @@ -386,11 +241,7 @@ fn attached_volume_contact_reaches_its_actual_solver_body() { let mut world = BoardWorld::new(vec![triangle(), triangle()]); let config = config(); let contacts = world.query_primitives(&[volume], config.query, config.retention); - assert_eq!( - contacts.len(), - 1, - "retention must preserve attached body identity" - ); + assert_eq!(contacts.len(), 1, "retention must preserve attached body identity"); assert_eq!(contacts[0].body_a, CollisionBody::Attached(0)); let settings = BoardStepSettings { simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), @@ -399,20 +250,12 @@ fn attached_volume_contact_reaches_its_actual_solver_body() { truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), force_point_y_offset: 0.0, }; - board.advance_attached( - contacts, - [0.0; 2], - settings, - AttachedStep { - bodies: vec![&mut attached], - contacts: &mut [], - joints: &mut [], - drives: &mut [], - }, - ); + board.advance_attached(contacts, [0.0; 2], settings, AttachedStep { + bodies: vec![&mut attached], + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }); assert!(attached.rates.linear_velocity.y > before + 0.5); - assert!( - board.contact_reports().is_empty(), - "skater contacts are not board observations" - ); + assert!(board.contact_reports().is_empty(), "skater contacts are not board observations"); } diff --git a/crates/skate-core/src/player/input_phase/air_output.rs b/crates/skate-core/src/player/input_phase/air_output.rs index 89775ea8..d593612f 100644 --- a/crates/skate-core/src/player/input_phase/air_output.rs +++ b/crates/skate-core/src/player/input_phase/air_output.rs @@ -21,6 +21,8 @@ pub struct AirOutputFields { pub trajectory_plane_samples_336: [f32; 25], pub known_air_valid_437: u8, pub launched_442: u8, + /// Common ProcessOutput82DB703C: TrajectorySelector9653. + pub flag_443: u8, pub flag_444: u8, /// Air324 high bits used by IsHandPlanting82BA55A8. pub handplant_flags_324: u32, diff --git a/crates/skate-core/src/player/input_phase/grind_input.rs b/crates/skate-core/src/player/input_phase/grind_input.rs new file mode 100644 index 00000000..788d3a66 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/grind_input.rs @@ -0,0 +1,45 @@ +//! Value snapshot copied by82D87E80 into Processed1104..1519 after manager post. +//! Native pointers are stable host identities; all geometry/control values are +//! retained independently. Zero is the native416-byte investigation reset. +use super::RawVector; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GrindInvestigationFields { + pub tangent_1104: RawVector, + pub point_1120: RawVector, + pub direction_1136: RawVector, + pub normal_1152: RawVector, + pub target_up_1168: RawVector, + pub entry_velocity_1184: RawVector, + pub front_contact_1200: RawVector, + pub rear_contact_1216: RawVector, + pub vector_1232: RawVector, + pub family_1248: u32, + pub entry_kind_1252: u32, + pub primitive_start_1264: RawVector, + pub primitive_end_1280: RawVector, + pub owner_1296: Option, + pub primitive_flags_1300: u32, + pub second_start_1312: RawVector, + pub second_end_1328: RawVector, + pub second_owner_1344: Option, + pub second_flags_1348: u32, + pub center_1360: RawVector, + pub far_points_1376_1392: [RawVector; 2], + pub upmost_normal_1408: RawVector, + pub surface_direction_1424: RawVector, + pub high_side_1440: RawVector, + pub normal_limits_1456_1460: [f32; 2], + pub geometry_kind_1464: u32, + pub audio_surface_1468: u32, + pub physics_surface_1472: u32, + pub geometry_flags_1476: u32, + pub valid_1488: bool, + pub impact_speed_1492: f32, + pub friction_1496: f32, + pub exit_lean_1500: f32, + pub yaw_1504: f32, + pub pitch_1508: f32, + pub gravity_relief_1512: f32, + pub flags_1516: u32, +} diff --git a/crates/skate-core/src/player/input_phase/grind_output.rs b/crates/skate-core/src/player/input_phase/grind_output.rs new file mode 100644 index 00000000..bf494fde --- /dev/null +++ b/crates/skate-core/src/player/input_phase/grind_output.rs @@ -0,0 +1,106 @@ +//! Typed Grinds PhysOut block. Offsets identify native producers, not Rust ABI. +//! Reset82DE3518; common Fill82D40EA0; tipslide Fill82D42788; +//! chromosome publication82DEE508; contact conditioner82DF0640. +use super::types::RawVector; +use crate::animation::output::attributes::AttributeName; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GrindOutputFields { + pub direction_0: RawVector, + pub point_16: RawVector, + pub normal_32: RawVector, + pub across_48: RawVector, + pub primitive_start_64: RawVector, + pub primitive_end_80: RawVector, + pub camera_target_96: RawVector, + pub high_side_112: RawVector, + pub impact_speed_128: f32, + pub crouch_132: f32, + /// Physical family0..5 (not admission kind), then substate. + pub words_136_140: [u32; 2], + pub animation_id_144: u32, + pub scoring_id_148: u32, + pub scorable_id_152: u32, + /// Five-word stock FastString representation, not a hash of the display name. + /// None means the external reset-name template has not been supplied. + pub animation_name_156: Option, + pub scoring_name_176: Option, + pub surface_name_196: Option, + pub audio_surface_216: u32, + pub spline_guids_224_232: [u64; 2], + /// Common physical classification104, consumed by GrindTrickOutTypeAllowed. + pub trick_out_240: u32, + /// Approach, location, twist, tilt, orientation, family. No game enum here. + pub volatile_chromosome_244: [u32; 6], + /// First component at268 is the graph's filtered approach observation. + pub animation_chromosome_268: [u32; 6], + pub scoring_chromosome_292: [u32; 6], + /// Selector-owned; the camera conditioner must not infer this flag. + pub grinding_316: u8, + pub leaving_317: u8, + pub flag_318: u8, + pub flag_319: u8, + pub is_ledge_320: u8, + pub curb_321: u8, + pub flag_322: u8, + pub flag_323: u8, + pub dropping_in_324: u8, + pub tipslide_325: u8, + pub tipslide_326: u8, +} + +impl Default for GrindOutputFields { + fn default() -> Self { + const X: RawVector = [0x3f80_0000, 0, 0, 0]; + const Y: RawVector = [0, 0x3f80_0000, 0, 0]; + const EMPTY: [u32; 6] = [0, 0, 0, 0, 0, u32::MAX]; + Self { + direction_0: X, + point_16: [0; 4], + normal_32: Y, + across_48: Y, + primitive_start_64: [0; 4], + primitive_end_80: [0; 4], + camera_target_96: [0; 4], + high_side_112: [0; 4], + impact_speed_128: 0., + crouch_132: 0., + words_136_140: [u32::MAX, 0], + animation_id_144: 0, + scoring_id_148: 0, + scorable_id_152: u32::MAX, + //82F8AF08 initializes template830C0D60 from empty string82060799. + animation_name_156: Some(crate::animation::skeleton_input::name::encode(b"")), + scoring_name_176: Some(crate::animation::skeleton_input::name::encode(b"")), + surface_name_196: None, + audio_surface_216: 0, + spline_guids_224_232: [u64::MAX; 2], + trick_out_240: 0, + volatile_chromosome_244: EMPTY, + animation_chromosome_268: EMPTY, + scoring_chromosome_292: EMPTY, + grinding_316: 0, + leaving_317: 0, + flag_318: 0, + flag_319: 0, + is_ledge_320: 0, + curb_321: 0, + flag_322: 0, + flag_323: 0, + dropping_in_324: 0, + tipslide_325: 0, + tipslide_326: 0, + } + } +} + +impl GrindOutputFields { + /// The native reset copies two external FastString templates; it does not + /// construct a grind name from family0. Supply decoded original templates. + /// Keeping these optional distinguishes an unavailable template from zeros. + pub fn reset_names(&mut self, grind: Option, surface: Option) { + self.animation_name_156 = grind; + self.scoring_name_176 = grind; + self.surface_name_196 = surface; + } +} diff --git a/crates/skate-core/src/player/input_phase/mod.rs b/crates/skate-core/src/player/input_phase/mod.rs index acc0c73f..56c2a81f 100644 --- a/crates/skate-core/src/player/input_phase/mod.rs +++ b/crates/skate-core/src/player/input_phase/mod.rs @@ -11,13 +11,16 @@ mod types; mod air_output; mod pose_output; mod requests; +mod grind_input; +mod grind_output; pub use runtime::{InputContinuation, InputPhaseError, InputPhaseServices, process_input, start_input, finish_input}; pub use types::*; pub use air_output::AirOutputFields; pub use pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; pub use requests::*; +pub use grind_input::GrindInvestigationFields; +pub use grind_output::GrindOutputFields; #[cfg(test)] mod tests; - diff --git a/crates/skate-core/src/player/input_phase/pose_output.rs b/crates/skate-core/src/player/input_phase/pose_output.rs index 84f9bca4..0105565c 100644 --- a/crates/skate-core/src/player/input_phase/pose_output.rs +++ b/crates/skate-core/src/player/input_phase/pose_output.rs @@ -6,6 +6,8 @@ use super::types::RawMatrix; pub struct SkeletonOutputFields { pub hips_right_angle_496: f32, pub hips_up_angle_500: f32, + /// Skeleton Fill82BE2268: signed physical twist, before animation mirroring. + pub twist_504: f32, pub scalar_544: f32, pub no_support_time_548: f32, pub time_until_teleport_576: f32, @@ -21,20 +23,48 @@ pub struct SkeletonOutputFields { pub anim_to_world_11920: RawMatrix, } +impl SkeletonOutputFields { + /// Skeleton Fill82BE2148..2268. Read the completed physical record, the + /// current input toolkit's Processed352 and Reckoning1152. No pose fitting + /// or animation mirror: GrindControlFade applies that mirror once on Begin. + pub fn publish_twist( + &mut self, + record: &crate::physics::skeleton_body::SkeletonPhysicalRecord, + processed_forward_352: [f32; 4], + reckoning_up_1152: [f32; 4], + ) { + use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic::dot3}; + // SkeletonState at6464, physical pose at+1552: translations of + // parts10/6 are Skeleton8704/8448, respectively. + let across: [f32; 4] = std::array::from_fn(|i| record.pose[10][3][i] - record.pose[6][3][i]); + let squared = dot3(across, across); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + // Original830BD350 is a zero vector. Preserve the ordered comparison, + // including the zero/NaN path selecting the zero direction. + let direction = if length > 0.0 { across.map(|v| v * inverse) } else { [0.0; 4] }; + let angle = crate::player::wipeout_state::orientation::projected_angle( + processed_forward_352, direction, reckoning_up_1152, + ); + // Original82139A60/50; wrap via fractional turns, not angle modulo pi. + let turns = angle * f32::from_bits(0x3E22_F983); + let fraction = turns - turns.floor(); + self.twist_504 = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) + * f32::from_bits(0x40C9_0FDB); + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct AnimationOutputFields { pub collision_time_144: f32, pub profile_148: u32, - /// PhysOutAnimation+166, tested by OkToDoTrickOnStairs82BA6930. pub tricks_blocked_on_stairs_166: u8, - /// Ground manual opposition, read by PhysicsWantsManualExit82BA7930. + /// Ground Fill82D3A4F0 -> PhysicsWantsManualExit82BA7988. pub manual_opposition_168: u8, } impl Default for AnimationOutputFields { fn default() -> Self { // Animation82DE3F38 stores FLT_MAX at144 and zero at148. - // Reset82DE3F38 clears byte166. Ordinary Ground Fill82D3A388 - // and common ProcessOutput82DB6EC0 do not overwrite this byte. Self { collision_time_144: f32::MAX, profile_148: 0, tricks_blocked_on_stairs_166: 0, manual_opposition_168: 0 } } } diff --git a/crates/skate-core/src/player/input_phase/types.rs b/crates/skate-core/src/player/input_phase/types.rs index f3dd765e..ad93ba87 100644 --- a/crates/skate-core/src/player/input_phase/types.rs +++ b/crates/skate-core/src/player/input_phase/types.rs @@ -39,6 +39,8 @@ pub struct CurrentStateFields { pub category_12: u32, pub state_16: u32, pub surface_height_32: f32, + ///BipedGround Fill82D32D38, independent of State40. + pub counter_36: u32, pub skitch_value_40: u32, pub flag_61: u8, pub flag_66: u8, @@ -73,7 +75,16 @@ pub struct SystemReckoningFields { #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct GroundOutputFields { + ///State503 Fill82D4E090..E0C8. + pub landing_half_turns_312: i32, + pub hippy_jumping_322: u8, + pub hippy_takeoff_323: u8, pub vector_64: RawVector, + /// Board Fill82C03318: wheel-contact normal (not Motion80 velocity). + pub vector_80: RawVector, + /// Host storage for Motion273, NOT native Ground273. Common82DB7598 + /// publishes Processed2468 bit20; keep this distinct from the normal. + pub flag_273: u8, pub vector_96: RawVector, pub vector_128: RawVector, pub scalar_276: f32, @@ -87,6 +98,8 @@ pub struct GroundOutputFields { pub struct CollisionOutputFields { pub wheel_count_0: u32, pub scalar_28: f32, + ///Offboard landing manager82D79498; reset82DE3290 clears this position. + pub vector_48: RawVector, pub predicted_position_64: RawVector, pub flag_215: u8, pub flag_216: u8, @@ -98,6 +111,8 @@ pub struct CollisionOutputFields { pub flag_3479: u8, pub flag_3480: u8, pub flag_3481: u8, + ///Offboard landing manager82D7948C; reset82DE32E4 clears availability. + pub flag_3482: u8, pub flag_3483: u8, } @@ -108,34 +123,11 @@ pub struct PhysicsOutputFields { pub vector_16: RawVector, } -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct GrindOutputFields { - pub words_136_140: [u32; 2], - pub flag_318: u8, - pub flag_322: u8, - /// PhysOut.Grinds324: reset82DE3518 clears it; tipslide Fill82D42788 - /// publishes its retained drop-in byte97. Read by IsDroppingIn82BA41F0. - pub dropping_in_324: u8, -} +use super::GrindOutputFields; #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct OffBoardOutputFields { - pub landed_320: u8, - pub flag_331: u8, - pub air_duration_92: f32, - pub landing_direction_96: [f32; 4], - pub air_surface_144: u32, - pub impact_y_148: f32, - pub air_time_152: f32, - pub apex_time_156: f32, - pub launch_up_160: [f32; 4], - pub launch_position_176: [f32; 4], - pub landing_normal_192: [f32; 4], - pub landing_position_208: [f32; 4], - pub landing_forward_224: [f32; 4], - pub apex_position_240: [f32; 4], - pub phase_80: f32, - pub locomotion_84: u32, + ///BipedGround Fill82D32D38 and shared Feet Fill82D785F8. pub kind_88: u32, pub scalar_112: f32, pub distance_116: f32, @@ -143,15 +135,25 @@ pub struct OffBoardOutputFields { pub flag_329: u8, pub flag_330: u8, pub flag_334: u8, + ///Common ProcessOutput82DB724C..7258 from the shared Biped720/716. + pub cadence_phase_80: f32, + pub locomotion_state_84: u32, + /// SkateboardController FillPhysOut82D76D20 orientation outputs +36/+40. + pub angle_36: f32, + pub angle_40: f32, + /// Reset82DE40C0; Biped trajectory publication writes the duration here. + pub trajectory_time_120: f32, + /// Reset82DE4170; BipedAir Fill82D30324 publishes trajectory availability. + pub trajectory_valid_331: u8, pub scalar_32: f32, - pub board_angle_36: f32, - pub board_angle_40: f32, pub flag_304: u8, + ///Common ProcessOutput82DB76F4: Processed2480 bit19, not grab-object304. + pub flag_308: u8, pub flag_311: u8, + ///SkateboardController82D76D88: state448 is FREE/HIDING/RETURNING. pub free_board_312: u8, + ///82D76DA0: physical RETURNING state, distinct from animation activity323. pub returning_board_313: u8, - pub hidden_board_321: u8, - pub board_request_324: u8, pub flag_314: u8, pub flag_315: u8, pub flag_316: u8, @@ -161,10 +163,28 @@ pub struct OffBoardOutputFields { pub hippy_hurdling_317: u8, pub flag_318: u8, pub dropping_board_322: u8, + ///82D76ED4: state448 is HIDING. + pub hiding_board_321: u8, pub flag_319: u8, pub retrieving_board_323: u8, + ///82D76F68: retrieving animation OR retrieve pulse while RETURNING. + pub flag_324: u8, pub flag_328: u8, pub vector_64: RawVector, + ///BipedAir Fill82D30300: distinct from the on-board Air output block. + pub scalar_92: f32, + pub vector_96: RawVector, + pub word_144: u32, + pub scalar_148: f32, + pub scalar_152: f32, + pub scalar_156: f32, + pub vector_160: RawVector, + pub vector_176: RawVector, + pub vector_192: RawVector, + pub vector_208: RawVector, + pub vector_224: RawVector, + pub vector_240: RawVector, + pub flag_320: u8, } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -313,8 +333,6 @@ impl Default for PlayerInputState { #[derive(Clone, Copy, Debug, PartialEq)] pub struct ProcessedPhysicsInput { - pub grind_position_1120: RawVector, - pub grind_direction_1136: RawVector, pub effective_anim_transform_192: RawMatrix, pub vectors_400_416: [RawVector; 2], pub vectors_464_480_496_512_528: [RawVector; 5], @@ -328,11 +346,16 @@ pub struct ProcessedPhysicsInput { pub vectors_720_784_800_816_832_864: [RawVector; 6], pub vectors_880_896_912_928_944: [RawVector; 5], pub line_tests_960_1008_1056: [LineTestFields; 3], + pub grind: super::GrindInvestigationFields, pub vector_1520: RawVector, pub matrix_1536: RawMatrix, pub byte_1600: u8, pub external_physics_1616: ExternalPhysicsInput, pub probe_1792: ProbeFields, + /// Actual grab-spline publication payloads; flags2480 bits22/21 carry readiness. + pub grab_records_1888_2176: [[u32; 72]; 2], + /// Interactable identity published by82D740F8; bit20 carries validity. + pub object_2464: u32, pub flags_2468: u32, pub flags_2472: u32, pub flags_2476: u32, @@ -362,6 +385,8 @@ pub struct ProcessedPhysicsInput { pub right_surface_2600: u32, pub timestep_2604: f32, pub scalar_2612: f32, + ///PrepareBoardToolkit82C01744: signed speed times its ordered travel sign. + pub scalar_2616: f32, pub transition_2636: f32, ///Skeleton82BDD008: BodySpin plus the actual grind-air adjustment result. ///Read only with flags2484bit15; Reset82BFA394 clears it each input tick. @@ -416,8 +441,6 @@ impl ProcessedPhysicsSnapshot { impl Default for ProcessedPhysicsInput { fn default() -> Self { Self { - grind_position_1120: [0;4], - grind_direction_1136: [0;4], effective_anim_transform_192: [[0; 4]; 4], vectors_400_416: [[0; 4]; 2], vectors_464_480_496_512_528: [[0; 4]; 5], @@ -430,11 +453,14 @@ impl Default for ProcessedPhysicsInput { vectors_720_784_800_816_832_864: [[0; 4]; 6], vectors_880_896_912_928_944: [[0; 4]; 5], line_tests_960_1008_1056: [LineTestFields::default(); 3], + grind: super::GrindInvestigationFields::default(), vector_1520: [0; 4], matrix_1536: [[0; 4]; 4], byte_1600: 0, external_physics_1616: empty_external_physics(), probe_1792: ProbeFields::default(), + grab_records_1888_2176: [[0; 72]; 2], + object_2464: 0, flags_2468: 0, flags_2472: 0, flags_2476: 0, @@ -464,6 +490,7 @@ impl Default for ProcessedPhysicsInput { right_surface_2600: 0, timestep_2604: 0.0, scalar_2612: 0.0, + scalar_2616: 0.0, transition_2636: 0.0, grind_adjusted_body_spin_2644: 0.0, gravity_2648: 0.0, diff --git a/crates/skate-core/src/player/lifecycle.rs b/crates/skate-core/src/player/lifecycle.rs index 3fb7adb7..05033b17 100644 --- a/crates/skate-core/src/player/lifecycle.rs +++ b/crates/skate-core/src/player/lifecycle.rs @@ -79,8 +79,8 @@ pub struct StateChangeData { /// implementations are `HoldSkateboard` `0x82D75370` and /// `LetGoOfSkateboard` `0x82D75440`; this lifecycle never replaces them. pub trait SkateboardControllerActions { - fn hold_skateboard(&mut self, _fields: &mut SkateboardControllerFields); - fn let_go_of_skateboard(&mut self, _fields: &mut SkateboardControllerFields); + fn hold_skateboard(&mut self); + fn let_go_of_skateboard(&mut self); } /// Context supplied to an owned state's Enter or Exit implementation. @@ -196,11 +196,11 @@ fn prepare_skateboard_controller( controller.word_444 = 0; if processed_flags_2480 & (0x80 | 0x100) != 0 { if controller.state_448 != 2 { - actions.let_go_of_skateboard(controller); + actions.let_go_of_skateboard(); controller.state_448 = 2; } } else if controller.state_448 != 1 { - actions.hold_skateboard(controller); + actions.hold_skateboard(); controller.state_448 = 1; } controller.system_on_452 = true; @@ -218,7 +218,7 @@ fn stop_skateboard_controller( let previous_state = controller.state_448; controller.word_444 = 0; if previous_state != 0 { - actions.let_go_of_skateboard(controller); + actions.let_go_of_skateboard(); controller.state_448 = 0; } controller.system_on_452 = false; diff --git a/crates/skate-core/src/player/offboard/air_launch.rs b/crates/skate-core/src/player/offboard/air_launch.rs index ce2bbecd..6976a9fd 100644 --- a/crates/skate-core/src/player/offboard/air_launch.rs +++ b/crates/skate-core/src/player/offboard/air_launch.rs @@ -1,276 +1,151 @@ -//! TU3 Biped launch preparation82D7BA78. This packet is consumed by the -//! off-board trajectory owner82D6CA58; it never substitutes a landing query. +//! Original Biped launch82D7BA78. This is separate from onboard LaunchInfo. +//! Produces the full packet before Sync replaces position32 with its body point. +use super::controller; use crate::point_graph::PointGraph; -pub type V = [f32; 4]; -const UP: V = [0., 1., 0., 0.]; -const ZERO: V = [0.; 4]; +mod jump; +pub(super) mod math; +mod packet; +use math::*; +pub use packet::Packet; +#[cfg(test)] +mod tests; +pub type Vector = [f32; 4]; + +/// Actual physics_biped scalar fields, validated by the stock loader. #[derive(Clone, Copy, Debug)] -pub struct Launch { - pub velocity: V, - pub secondary_velocity: V, - pub position: V, - pub up: V, - pub forward: V, - pub target: V, - pub angles: [f32; 3], - pub primary_count: u32, - pub secondary_count: u32, - pub has_target: bool, - pub flag_117: bool, +pub struct Settings { + /// Biped796 <- layout896 (0x380), JumpSpeedScalar. + pub jump_speed_scalar: f32, + /// Biped792 <- layout900 (0x384), JumpHeight. + pub jump_height: f32, } -impl Default for Launch { - fn default() -> Self { - Self { - velocity: ZERO, - secondary_velocity: ZERO, - position: ZERO, - up: UP, - forward: ZERO, - target: ZERO, - angles: [0.; 3], - primary_count: 1, - secondary_count: 0, - has_target: false, - flag_117: false, - } - } +/// A per-call snapshot from the canonical processed input; no persistent copy. +///Source1120 is a point and1136 is the axis used by mode1's rejection. +///Absence means the host has no source-backed departure geometry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepartureGeometry { + pub point_1120: Vector, + pub axis_1136: Vector, } -pub struct Input { - pub previous_state: u32, - pub previous_category: u32, - pub current_state: u32, - pub current_category: u32, +#[derive(Clone, Copy, Debug)] +pub struct Processed { + pub board_position_112: Vector, + pub forward_224: Vector, + pub up_544: Vector, + pub position_592: Vector, + pub velocity_608: Vector, + pub velocity_912: Vector, + pub departure_geometry: Option, pub flags_2472: u32, pub flags_2476: u32, pub flags_2480: u32, - pub frame_forward_224: V, - pub up_544: V, - pub velocity_608: V, - pub position_592: V, - pub target_112: V, - pub velocity_912: V, - pub grind_position_1120: V, - pub grind_axis_1136: V, - pub stick: [f32; 2], + pub previous_state_2504: u32, + pub current_state_2508: u32, + pub current_category_2512: u32, + pub previous_category_2516: u32, + pub raw_x_2692: f32, + pub raw_z_2688: f32, } -pub struct JumpInput<'a> { - pub reference_up_144: V, - pub contact_active_708: bool, - pub height_792: f32, - pub velocity_scalar_796: f32, - pub speed_704: f32, - pub steering_760: f32, - pub angular_velocity_688: f32, - pub turn_curve_1328: &'a PointGraph<8>, -} -pub fn prepare(p: &Input, j: &JumpInput<'_>, current: bool) -> Launch { +///82D7BA90..BB38, including the current/previous selector used by its callers. +pub fn mode(p: &Processed, current: bool) -> u8 { let (state, category) = if current { - (p.current_state, p.current_category) + (p.current_state_2508, p.current_category_2512) } else { - (p.previous_state, p.previous_category) - }; - let mut forward = p.frame_forward_224; - forward[1] = 0.; - let forward = unit(forward, ZERO); - let mut out = Launch { - velocity: p.velocity_608, - secondary_velocity: p.velocity_608, - position: p.position_592, - up: p.up_544, - forward, - angles: [ - f32::from_bits(0x3db2b8c2), - f32::from_bits(0x3f5f66f3), - f32::from_bits(0x3f32b8c2), - ], - ..Launch::default() + (p.previous_state_2504, p.previous_category_2516) }; match category { - 100 => { - out.target = p.target_112; - out.has_target = true; - let y = out.velocity[1]; - let lower = select(y - 3., y, 3.); - out.velocity[1] = select(y + 3. - lower, lower, y + 3.); - out.position = madd(out.velocity, f32::from_bits(0x3c888889), out.position); + 100 => 0, + 400 => 1, + 200 => 2, + 500 if p.flags_2480 & 0x80 != 0 || state == 503 => 3, + 500 if p.flags_2476 & 0x80000 != 0 => 4, + 500 => 5, + _ => 6, + } +} +/// Borrow the existing controller state and its movement_velocity.turn_vs_speed. +/// Fields80/116/117 retain the caller's packet unless the original mode writes +/// them. Common scalars, both velocities, position, up and forward always write. +pub fn produce( + packet: &mut Packet, + state: &controller::State, + turn_vs_speed: &PointGraph<8>, + settings: Settings, + p: &Processed, + current: bool, +) -> Result<(), &'static str> { + let selected_mode = mode(p, current); + //Validate the host contract before ANY packet field changes. No numerical + //fallback or inferred geometry is substituted for an unavailable producer. + let departure = + if selected_mode == 1 { + Some(p.departure_geometry.ok_or( + "Offboard launch mode1 requires source-backed departure geometry1120/1136", + )?) + } else { + None + }; + let forward = normalize_or(flatten(p.forward_224), ZERO); + packet.up_48 = p.up_544; + packet.forward_64 = forward; + packet.scalar_96 = f32::from_bits(0x3db2_b8c2); + packet.scalar_100 = f32::from_bits(0x3f5f_66f3); + packet.scalar_104 = f32::from_bits(0x3f32_b8c2); + packet.kind_108 = 1; + packet.kind_112 = 0; + packet.position_32 = p.position_592; + let mut velocity = p.velocity_608; + let mut secondary = velocity; + match selected_mode { + 0 => { + //BC84..BCC0 is a boost, not a conventional [-3,3] clamp. + let lower = select(velocity[1] - 3.0, velocity[1], 3.0); + velocity[1] = select((velocity[1] + 3.0) - lower, lower, velocity[1] + 3.0); + packet.board_position_80 = p.board_position_112; + packet.has_board_position_116 = true; + packet.position_32 = madd(velocity, DT, packet.position_32); } - 400 => { - out.primary_count = 6; - let delta = sub(p.position_592, p.grind_position_1120); - let mut perpendicular = sub( + 1 => { + let departure = departure.expect("Mode1 geometry validated before packet writes"); + let delta = sub(p.position_592, departure.point_1120); + let projected = sub( delta, - scale(p.grind_axis_1136, dot(delta, p.grind_axis_1136)), + scale(departure.axis_1136, dot(delta, departure.axis_1136)), ); - perpendicular[1] = 0.; - out.velocity = madd(unit(perpendicular, ZERO), 2., out.velocity); - out.velocity = add(out.velocity, UP); - out.position = madd(out.velocity, f32::from_bits(0x3c888889), out.position); + let normal = normalize_or(flatten(projected), ZERO); + velocity = add(madd(normal, 2.0, velocity), UP); + packet.position_32 = madd(velocity, DT, packet.position_32); + packet.kind_108 = 6; } - 500 if p.flags_2480 & 0x80 != 0 || state == 503 => { - let side = cross(UP, out.velocity); - let n = length(side); - if n > f32::from_bits(0x3a83126f) { - out.velocity = madd( - side, - (if p.flags_2476 & 4 != 0 { 0.75 } else { -0.75 }) / n, - out.velocity, - ); + 3 => { + let side = cross(UP, velocity); + let speed = length(side); + if !(speed <= f32::from_bits(0x3a83_126f)) { + let offset = if p.flags_2476 & 4 != 0 { 0.75 } else { -0.75 }; + velocity = madd(side, offset / speed, velocity); } } - 500 if p.flags_2476 & 0x80000 != 0 => jump(&mut out, p, j), - 500 => { - let mut horizontal = out.velocity; - horizontal[1] = 0.; - out.primary_count = 6; - if length(horizontal) < 1.875 { - out.angles[0] = f32::from_bits(0x3f060a92); - out.velocity = scale(unit(horizontal, forward), 2.5); - out.velocity[1] = select(1. - out.velocity[1], 1., out.velocity[1]); + 4 => { + (velocity, secondary) = jump::calculate(state, turn_vs_speed, settings, p, forward); + packet.kind_108 = 6; + packet.kind_112 = 3; + } + 5 => { + let horizontal = flatten(velocity); + if !(length(horizontal) >= 1.875) { + velocity = scale(normalize_or(horizontal, forward), 2.5); + velocity[1] = select(1.0 - velocity[1], 1.0, velocity[1]); + packet.scalar_96 = f32::from_bits(0x3f06_0a92); } else { - out.velocity = scale(p.velocity_912, 0.75); - out.velocity[1] = bounded(out.velocity[1], -10., 5.); + velocity = scale(p.velocity_912, 0.75); + velocity[1] = clamp(velocity[1], -10.0, 5.0); } + packet.kind_108 = 6; } - _ => {} - } - out -} -fn jump(out: &mut Launch, p: &Input, j: &JumpInput<'_>) { - let up = unit(j.reference_up_144, j.reference_up_144); - let tangent = sub(p.velocity_912, scale(up, dot(up, p.velocity_912))); - let amount = dot(tangent, out.forward); - let mut initial = madd(out.forward, select(-amount, 0., amount) - amount, tangent); - let mut redirected = initial; - let speed2 = dot(initial, initial); - if p.flags_2472 & 0x10000000 == 0 { - let stick = [p.stick[0], 0., p.stick[1], 0.]; - if speed2 < 1. { - initial = limit_length(add(initial, stick), 1.); - redirected = initial; - } else if j.contact_active_708 && dot(stick, stick) > f32::from_bits(0x3f4f5c28) { - let angle = - crate::player::wipeout_state::orientation::projected_angle(stick, initial, up); - let turns = angle * f32::from_bits(0x3e22f983); - let f = turns - turns.floor(); - let angle = (f - if f > 0.5 { 1. } else { 0. }) * f32::from_bits(0x40c90fdb); - let projected = sub(stick, scale(unit(up, ZERO), dot(stick, unit(up, ZERO)))); - if angle < 45. * f32::from_bits(0x3c8efa35) { - redirected = scale(unit_unchecked(projected), sqrt(speed2)); - } else if angle < 90. * f32::from_bits(0x3c8efa35) { - redirected = scale( - limit_angle( - unit_unchecked(projected), - initial, - 45. * f32::from_bits(0x3c8efa35), - ), - sqrt(speed2), - ); - } - } - } - let vertical = select( - -(up[1] * sqrt(j.height_792 * f32::from_bits(0x419ccccd))), - 0., - up[1] * sqrt(j.height_792 * f32::from_bits(0x419ccccd)), - ); - let mut horizontal = redirected; - horizontal[1] = 0.; - out.secondary_velocity = unit(horizontal, ZERO); - let requested = - j.turn_curve_1328.evaluate(j.speed_704) * j.steering_760 * f32::from_bits(0x3c8efa35); - let angle = bounded( - bounded( - requested, - j.angular_velocity_688 - f32::from_bits(0x3fdf66f3), - j.angular_velocity_688 + f32::from_bits(0x3fdf66f3), - ) * 0.4, - f32::from_bits(0xbe860a92), - f32::from_bits(0x3e860a92), - ); - out.velocity = rotate( - madd(initial, j.velocity_scalar_796, scale(up, vertical)), - up, - angle, - ); - out.primary_count = 6; - out.secondary_count = 3; -} -fn select(t: f32, a: f32, b: f32) -> f32 { - if t >= 0. { a } else { b } -} -fn bounded(v: f32, lo: f32, hi: f32) -> f32 { - let v = select(lo - v, lo, v); - select(hi - v, v, hi) -} -pub(super) fn dot(a: V, b: V) -> f32 { - crate::physics::native_arithmetic::dot3(a, b) -} -fn add(a: V, b: V) -> V { - std::array::from_fn(|i| a[i] + b[i]) -} -pub(super) fn sub(a: V, b: V) -> V { - std::array::from_fn(|i| a[i] - b[i]) -} -pub(super) fn scale(v: V, s: f32) -> V { - v.map(|x| x * s) -} -pub(super) fn madd(v: V, s: f32, b: V) -> V { - std::array::from_fn(|i| v[i].mul_add(s, b[i])) -} -fn inverse(q: f32) -> f32 { - let mut r = crate::physics::reciprocal_sqrt::estimate(q); - for _ in 0..2 { - r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.), r); - } - r -} -fn sqrt(q: f32) -> f32 { - if q == 0. { 0. } else { q * inverse(q) } -} -pub(super) fn length(v: V) -> f32 { - sqrt(dot(v, v)) -} -pub(super) fn unit(v: V, fallback: V) -> V { - let q = dot(v, v); - let inv = inverse(q); - if sqrt(q) > f32::from_bits(0x358637bd) { - scale(v, inv) - } else { - fallback - } -} -fn unit_unchecked(v: V) -> V { - scale(v, inverse(dot(v, v))) -} -pub(super) fn cross(a: V, b: V) -> V { - [ - (-a[2]).mul_add(b[1], a[1] * b[2]), - (-a[0]).mul_add(b[2], a[2] * b[0]), - (-a[1]).mul_add(b[0], a[0] * b[1]), - 0., - ] -} -pub(super) fn rotate(v: V, axis: V, angle: f32) -> V { - let (s, c) = crate::trigonometry::sin_cos(angle * 0.5); - let mut q = scale(axis, s); - q[3] = c; - crate::player::wipeout_state::orientation::rotate(q, v) -} -fn limit_length(v: V, limit: f32) -> V { - let n = length(v); - if n < f32::from_bits(0x37800000) { - return v; - } - scale(v, select(limit - n, n, limit) / n) -} -pub(super) fn limit_angle(proposed: V, reference: V, limit: f32) -> V { - let a = unit_unchecked(proposed); - let b = unit_unchecked(reference); - let angle = crate::trigonometry::acos(dot(a, b).max(-1.).min(1.)); - let axis = cross(a, b); - if angle.abs() < limit || dot(axis, axis) < f32::from_bits(0x37800000) { - proposed - } else { - scale(rotate(b, unit_unchecked(axis), -limit), length(proposed)) + 2 | 6 => {} + _ => unreachable!("mode returns only the seven native cases"), } + packet.velocity_0 = velocity; + packet.secondary_velocity_16 = secondary; + Ok(()) } diff --git a/crates/skate-core/src/player/offboard/air_launch/jump.rs b/crates/skate-core/src/player/offboard/air_launch/jump.rs new file mode 100644 index 00000000..e17817c5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_launch/jump.rs @@ -0,0 +1,54 @@ +//! Mode4:82D7C0C0..C7F4. Secondary obstacle direction and primary impulse +//! intentionally have different dataflow, and the source compares signed angles. +use super::{PointGraph, Processed, Settings, Vector, controller, math::*}; +use crate::player::wipeout_state::orientation::projected_angle; + +pub(super) fn calculate( + state: &controller::State, + turn: &PointGraph<8>, + settings: Settings, + p: &Processed, + forward: Vector, +) -> (Vector, Vector) { + let reference_up = state.frame_output.frame[1]; //Biped144 + // Preserve the explicit zero multiply and fallback to the original reference. + let blend = madd(reference_up, 1.0, scale(state.motion.frame_0[1], 0.0)); + let up = normalize_or(blend, reference_up); + let planar = sub(p.velocity_912, scale(up, dot(up, p.velocity_912))); + let backward = dot(planar, forward); + let amount = select(-backward, 0.0, backward) - backward; + let mut base = madd(forward, amount, planar); + let mut secondary = base; + let speed_squared = dot(base, base); + if p.flags_2472 & 0x1000_0000 == 0 { + let control = [p.raw_x_2692, 0.0, p.raw_z_2688, 0.0]; + if !(speed_squared >= 1.0) { + base = super::super::contact_correction::clamp_length(madd(control, 1.0, base), 1.0); + secondary = base; + } else if state.contact.active && dot(control, control) > f32::from_bits(0x3f4f_5c28) { + let angle = wrap_angle(projected_angle(control, base, up)); + if 45.0 * RADIANS > angle { + secondary = scale(normalize(reject(control, up)), magnitude(speed_squared)); + } else if 90.0 * RADIANS > angle { + let direction = normalize(reject(control, up)); + //C4E4 supplies45.0 exactly, with no radians conversion. + secondary = scale(limit_angle(direction, base, 45.0), magnitude(speed_squared)); + } + } + } + let launch_speed = magnitude(settings.jump_height * f32::from_bits(0x419c_cccd)); + let vertical = up[1] * launch_speed; + let vertical = select(-vertical, 0.0, vertical); + secondary = normalize_or(flatten(secondary), ZERO); + let requested_turn = (turn.evaluate(state.motion.speed_704) * state.intent.steering) * RADIANS; + //82D7C6C4 reads Biped+688; +672 is the unrelated edge-position vector. + let turn_center = state.motion.angular_velocity_688; + let window = f32::from_bits(0x3fdf_66f3); + let angle = clamp( + clamp(requested_turn, turn_center - window, turn_center + window) * 0.4, + f32::from_bits(0xbe86_0a92), + f32::from_bits(0x3e86_0a92), + ); + let impulse = madd(base, settings.jump_speed_scalar, scale(up, vertical)); + (rotate(impulse, up, angle), secondary) +} diff --git a/crates/skate-core/src/player/offboard/air_launch/math.rs b/crates/skate-core/src/player/offboard/air_launch/math.rs new file mode 100644 index 00000000..cf1a8447 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_launch/math.rs @@ -0,0 +1,61 @@ +//! Recovered leaf expressions. Ordinary PC estimates retain native refinements; +//! they are not claimed to be bit-exact Xenon arithmetic. +use super::Vector; +pub(in crate::player::offboard) use crate::player::wipeout_state::math::{ + add, clamp, cross, dot, length, madd, normalize, normalize_or, scale, sub, wrap_angle, +}; +pub(in crate::player::offboard) const ZERO: Vector = [0.0; 4]; +pub(in crate::player::offboard) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(in crate::player::offboard) const DT: f32 = f32::from_bits(0x3c88_8889); +pub(in crate::player::offboard) const RADIANS: f32 = f32::from_bits(0x3c8e_fa35); +pub(in crate::player::offboard) fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +pub(in crate::player::offboard) fn flatten(mut v: Vector) -> Vector { + v[1] = 0.0; + v +} +pub(in crate::player::offboard) fn magnitude(q: f32) -> f32 { + let value = q * crate::physics::board_motion_output::inverse_length_squared(q, 2); + if q == 0.0 { 0.0 } else { value } +} +///82E0A4E8: normalize axis safely, then separate multiplication/subtraction. +pub(in crate::player::offboard) fn reject(v: Vector, axis: Vector) -> Vector { + let axis = normalize_or(axis, ZERO); + sub(v, scale(axis, dot(v, axis))) +} +///82BD3E78, including its strict angle comparison and raw quaternion W insert. +pub(in crate::player::offboard) fn limit_angle(target: Vector, from: Vector, maximum: f32) -> Vector { + let a = normalize_or(target, ZERO); + let b = normalize_or(from, ZERO); + let axis = cross(a, b); + let angle = crate::trigonometry::acos(dot(a, b).max(-1.0).min(1.0)); + let turns = angle.mul_add(f32::from_bits(0x3e22_f983), 0.5).floor(); + let closest = (-turns).mul_add(f32::from_bits(0x40c9_0fdb), angle); + if closest.abs() < maximum || dot(axis, axis) < f32::from_bits(0x3780_0000) { + return target; + } + let (s, c) = crate::trigonometry::sin_cos(-maximum * 0.5); + let mut q = scale(normalize(axis), s); + q[3] = c; + let first = cross(q, from); + let middle = madd(from, c, first); + scale(madd(cross(q, middle), 2.0, from), length(target)) +} +///82D7C724..C7F4 XYZ Rodrigues rotation. Host representation adaptation: +///native permutation scratch lanes are not a fourth spatial velocity component. +///Keep the geometric vector W=0 at this producer, before trajectory feedback. +pub(in crate::player::offboard) fn rotate(v: Vector, axis: Vector, angle: f32) -> Vector { + let [x, y, z, _] = axis; + let (s, c) = crate::trigonometry::sin_cos(angle); + let t = 1.0 - c; + let (tx, ty, tz) = (t * x, t * y, t * z); + let (sx, sy, sz) = (s * x, s * y, s * z); + let xx = tx.mul_add(x, c); + let yx = ty * x - sz; + let zx = tz.mul_add(x, sy); + let right = [xx, tx.mul_add(y, sz), tx * z - sy, 0.]; + let up = [yx, ty.mul_add(y, c), ty.mul_add(z, sx), 0.]; + let forward = [zx, tz * y - sx, tz.mul_add(z, c), 0.]; + madd(forward, v[2], madd(up, v[1], scale(right, v[0]))) +} diff --git a/crates/skate-core/src/player/offboard/air_launch/packet.rs b/crates/skate-core/src/player/offboard/air_launch/packet.rs new file mode 100644 index 00000000..b4ab22dc --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_launch/packet.rs @@ -0,0 +1,40 @@ +use super::Vector; +/// Native118-byte logical packet. W lanes and the two trailing bytes are real +/// fields; this host struct makes no promise about the C ABI or native padding. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Packet { + pub velocity_0: Vector, + pub secondary_velocity_16: Vector, + pub position_32: Vector, + pub up_48: Vector, + pub forward_64: Vector, + pub board_position_80: Vector, + pub scalar_96: f32, + pub scalar_100: f32, + pub scalar_104: f32, + pub kind_108: u32, + pub kind_112: u32, + pub has_board_position_116: bool, + pub flag_117: bool, +} +impl Packet { + ///82D2DB98 does not write104; the caller supplies its retained value. + /// The launch producer subsequently overwrites104 in every mode. + pub fn initialized(retained_104: f32) -> Self { + Self { + velocity_0: [0.0; 4], + secondary_velocity_16: [0.0; 4], + position_32: [0.0; 4], + up_48: [0.0, 1.0, 0.0, 0.0], + forward_64: [0.0; 4], + board_position_80: [0.0; 4], + scalar_96: 0.0, + scalar_100: 0.0, + scalar_104: retained_104, + kind_108: 1, + kind_112: 0, + has_board_position_116: false, + flag_117: false, + } + } +} diff --git a/crates/skate-core/src/player/offboard/air_launch/tests.rs b/crates/skate-core/src/player/offboard/air_launch/tests.rs new file mode 100644 index 00000000..907fd833 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_launch/tests.rs @@ -0,0 +1,371 @@ +use super::*; +fn processed() -> Processed { + Processed { + board_position_112: [7.0, 8.0, 9.0, 2.0], + forward_224: [0.0, 0.0, 1.0, 0.0], + up_544: UP, + position_592: [10.0, 20.0, 30.0, 1.0], + velocity_608: [0.0; 4], + velocity_912: [0.0; 4], + departure_geometry: None, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + previous_state_2504: 500, + current_state_2508: 500, + current_category_2512: 500, + previous_category_2516: 500, + raw_x_2692: 0.0, + raw_z_2688: 0.0, + } +} +fn curve() -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [90.; 8], + } +} +#[test] +fn mode4_turn_clamp_reads_angular_velocity_not_edge_position() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.velocity_912 = [0., 0., 4., 0.]; + let mut state = controller::State::new([None; 3]); + state.motion.angular_velocity_688 = 4.0; + state.intent.edge_target[0] = -100.0; + let mut first = Packet::initialized(0.0); + produce(&mut first, &state, &curve(), settings(), &p, true).unwrap(); + state.intent.edge_target[0] = 100.0; + let mut second = Packet::initialized(0.0); + produce(&mut second, &state, &curve(), settings(), &p, true).unwrap(); + //82D7C6C4 loads Biped+688, not the separate edge-position vector+672. + assert_eq!(first.velocity_0, second.velocity_0); + state.motion.angular_velocity_688 = -4.0; + let mut opposite = Packet::initialized(0.0); + produce(&mut opposite, &state, &curve(), settings(), &p, true).unwrap(); + assert_ne!(second.velocity_0, opposite.velocity_0); +} +fn settings() -> Settings { + Settings { + jump_speed_scalar: 0.8, + jump_height: 0.9, + } +} +fn run(p: Processed) -> Packet { + let mut packet = Packet::initialized(f32::NAN); + produce( + &mut packet, + &controller::State::new([None; 3]), + &curve(), + settings(), + &p, + true, + ) + .unwrap(); + packet +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 2.0e-5, "{a} != {b}"); +} +fn vector(a: Vector, b: Vector) { + for i in 0..4 { + near(a[i], b[i]); + } +} +#[test] +fn all_seven_dispatch_modes_and_previous_state_selector() { + let mut p = processed(); + for (category, expected) in [(100, 0), (400, 1), (200, 2), (700, 6)] { + p.current_category_2512 = category; + assert_eq!(mode(&p, true), expected); + } + p.current_category_2512 = 500; + assert_eq!(mode(&p, true), 5); + p.flags_2476 = 0x80000; + assert_eq!(mode(&p, true), 4); + p.flags_2480 = 0x80; + assert_eq!(mode(&p, true), 3); + p.flags_2480 = 0; + p.current_state_2508 = 503; + assert_eq!(mode(&p, true), 3); + p.previous_category_2516 = 200; + assert_eq!(mode(&p, false), 2); +} +#[test] +fn initializer_preserves_only_unwritten_scalar104() { + let p = Packet::initialized(123.0); + assert_eq!(p.scalar_104, 123.0); + assert_eq!(p.up_48, UP); + assert_eq!(p.board_position_80, ZERO); + assert!(!p.flag_117); +} +#[test] +fn passive_modes_publish_common_fields_and_preserve_caller_fields() { + for category in [200, 700] { + let mut p = processed(); + p.current_category_2512 = category; + p.velocity_608 = [1., 2., 3., 4.]; + p.forward_224 = [0., 99., 2., 6.]; + let mut packet = Packet::initialized(f32::NAN); + packet.board_position_80 = [4.; 4]; + packet.has_board_position_116 = true; + packet.flag_117 = true; + produce( + &mut packet, + &controller::State::new([None; 3]), + &curve(), + settings(), + &p, + true, + ) + .unwrap(); + assert_eq!(packet.velocity_0, p.velocity_608); + assert_eq!(packet.secondary_velocity_16, p.velocity_608); + vector(packet.forward_64, [0., 0., 1., 3.]); + assert_eq!(packet.position_32, p.position_592); + assert_eq!(packet.scalar_104.to_bits(), 0x3f32_b8c2); + assert_eq!(packet.board_position_80, [4.; 4]); + assert!(packet.has_board_position_116 && packet.flag_117); + } +} +#[test] +fn riding_launch_boost_is_not_a_clamp_and_advances_all_lanes() { + for (y, want) in [ + (-10., -7.), + (-2., 1.), + (0., 3.), + (2., 3.), + (3., 3.), + (8., 8.), + ] { + let mut p = processed(); + p.current_category_2512 = 100; + p.velocity_608 = [2., y, 4., 6.]; + let packet = run(p); + assert_eq!(packet.velocity_0[1], want); + assert_eq!(packet.secondary_velocity_16, p.velocity_608); + assert_eq!(packet.board_position_80, p.board_position_112); + assert!(packet.has_board_position_116); + near(packet.position_32[1], want.mul_add(DT, 20.)); + near(packet.position_32[3], 6.0f32.mul_add(DT, 1.)); + } +} +#[test] +fn mode1_projects_away_from_supplied_axis_before_side_impulse() { + let mut p = processed(); + p.current_category_2512 = 400; + p.position_592 = [3., 50., 4., 0.]; + p.departure_geometry = Some(DepartureGeometry { + point_1120: [0.; 4], + axis_1136: UP, + }); + let packet = run(p); + vector(packet.velocity_0, [1.2, 1., 1.6, 0.]); + assert_eq!(packet.kind_108, 6); + vector(packet.secondary_velocity_16, ZERO); +} +#[test] +fn mode3_sideways_sign_and_stationary_guard() { + let mut p = processed(); + p.current_state_2508 = 503; + assert_eq!(run(p).velocity_0, ZERO); + p.velocity_608 = [0., 1., 2., 0.]; + near(run(p).velocity_0[0], -0.75); + p.flags_2476 = 4; + near(run(p).velocity_0[0], 0.75); + assert_eq!(run(p).position_32, p.position_592); +} +#[test] +fn mode5_speeds_either_side_and_fast_vertical_caps() { + let mut p = processed(); + p.velocity_608 = [0., 0., 1.874, 0.]; + p.velocity_912 = [4., 20., 8., 12.]; + let slow = run(p); + vector(slow.velocity_0, [0., 1., 2.5, 0.]); + assert_eq!(slow.scalar_96.to_bits(), 0x3f06_0a92); + //BF84 compares the refined magnitude, not the raw input component. + //Use a value unambiguously above1.875 for the fast/capping contract. + p.velocity_608[2] = 1.876; + let fast = run(p); + vector(fast.velocity_0, [3., 5., 6., 9.]); + assert_eq!(fast.scalar_96.to_bits(), 0x3db2_b8c2); + p.velocity_912[1] = -20.; + assert_eq!(run(p).velocity_0[1], -10.); +} +#[test] +fn mode5_pc_refinement_at_raw_1875_is_below_comparison_threshold() { + //Independent Single/FMA evaluation of S3 BF34..BF88 with our documented + //PC seed: square3.515625; seed3F088889; two refinements3F088888; + //multiply gives3FEFFFFF, one ULP below threshold3FF00000. + //This is a regression for our PC arithmetic, NOT Xenon estimate parity. + let mut p = processed(); + p.velocity_608 = [0., 0., 1.875, 0.]; + p.velocity_912 = [4., 20., 8., 12.]; + assert_eq!(length(p.velocity_608).to_bits(), 0x3fef_ffff); + let packet = run(p); + vector(packet.velocity_0, [0., 1., 2.5, 0.]); + assert_eq!(packet.scalar_96.to_bits(), 0x3f06_0a92); +} +#[test] +fn mode5_stationary_uses_flattened_forward_including_w() { + let mut p = processed(); + p.forward_224 = [0., 7., 2., 4.]; + vector(run(p).velocity_0, [0., 1., 2.5, 5.]); +} +#[test] +fn jump_stationary_control_and_impulse_use_separate_stock_scalars() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.raw_x_2692 = 0.6; + p.raw_z_2688 = 0.8; + let packet = run(p); + near(packet.velocity_0[0], 0.48); + near(packet.velocity_0[2], 0.64); + near(packet.velocity_0[1], (0.9f32 * 19.6).sqrt()); + // Host geometric representation deliberately excludes native scratch W. + // XYZ stock impulse assertions above remain unchanged. + assert_eq!(packet.velocity_0[3], 0.); + vector(packet.secondary_velocity_16, [0.6, 0., 0.8, 0.]); + assert_eq!((packet.kind_108, packet.kind_112), (6, 3)); +} +#[test] +fn jump_processed_gate_suppresses_stationary_stick_impulse() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.flags_2472 = 0x1000_0000; + p.raw_x_2692 = 1.; + let packet = run(p); + assert_eq!(packet.velocity_0[0], 0.); + assert_eq!(packet.secondary_velocity_16, ZERO); +} +#[test] +fn jump_backward_velocity_is_removed_before_control() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.flags_2472 = 0x1000_0000; + p.velocity_912 = [0., -9., -3., 0.]; + let packet = run(p); + near(packet.velocity_0[2], 0.); + assert_eq!(packet.secondary_velocity_16, ZERO); +} +#[test] +fn obstacle_positive_sixty_degree_branch_keeps_raw_45_argument() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.velocity_912 = [0., 0., 2., 0.]; + p.raw_x_2692 = -0.8660254; + p.raw_z_2688 = 0.5; + let mut state = controller::State::new([None; 3]); + state.contact.active = true; + let mut packet = Packet::initialized(0.); + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + near(packet.secondary_velocity_16[0], -0.8660254); + near(packet.secondary_velocity_16[2], 0.5); + // Secondary changes do not replace the base vector used for primary impulse. + near(packet.velocity_0[0], 0.); + near(packet.velocity_0[2], 1.6); +} +#[test] +fn obstacle_signed_negative_angle_and_over_ninety_branch() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.velocity_912 = [0., 0., 2., 0.]; + let mut state = controller::State::new([None; 3]); + state.contact.active = true; + let mut packet = Packet::initialized(0.); + p.raw_x_2692 = 1.; + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + vector(packet.secondary_velocity_16, [1., 0., 0., 0.]); + p.raw_x_2692 = -1.; + p.raw_z_2688 = -0.1; + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + vector(packet.secondary_velocity_16, [0., 0., 1., 0.]); +} +#[test] +fn jump_slope_frame_reduces_vertical_impulse_by_up_y() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.flags_2472 = 0x1000_0000; + let mut state = controller::State::new([None; 3]); + state.frame_output.frame[1] = [0.6, 0.8, 0., 0.]; + let mut packet = Packet::initialized(0.); + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + let lift = (0.9f32 * 19.6).sqrt() * 0.8; + near(packet.velocity_0[0], lift * 0.6); + near(packet.velocity_0[1], lift * 0.8); + assert_eq!(packet.up_48, UP); //Common output retains processed544. +} +#[test] +fn steering_uses_live760_then_angular_velocity_window_and_final_angle_cap() { + let mut p = processed(); + p.flags_2476 = 0x80000; + p.flags_2472 = 0x1000_0000; + p.velocity_912 = [0., 0., 2., 0.]; + let mut state = controller::State::new([None; 3]); + state.intent.steering = 1.; + state.intent.original_steering = -1.; + let mut packet = Packet::initialized(0.); + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + let (sin, cos) = crate::trigonometry::sin_cos(f32::from_bits(0x3e86_0a92)); + near(packet.velocity_0[0], 1.6 * sin); + near(packet.velocity_0[2], 1.6 * cos); + //82D7C6C4: the previous fixture incorrectly populated Biped+672. + state.motion.angular_velocity_688 = -3.; + produce(&mut packet, &state, &curve(), settings(), &p, true).unwrap(); + assert!(packet.velocity_0[0] < 0.); +} +#[test] +fn missing_departure_geometry_fails_before_any_packet_write() { + let mut p = processed(); + p.previous_category_2516 = 400; + let mut packet = Packet::initialized(123.); + packet.velocity_0 = [9.; 4]; + packet.position_32 = [8.; 4]; + let before = packet; + let result = produce( + &mut packet, + &controller::State::new([None; 3]), + &curve(), + settings(), + &p, + false, + ); + assert_eq!( + result, + Err("Offboard launch mode1 requires source-backed departure geometry1120/1136") + ); + assert_eq!(packet, before); +} +#[test] +fn absent_departure_geometry_is_valid_outside_selected_mode1() { + let mut p = processed(); + p.previous_category_2516 = 400; + p.current_category_2512 = 200; + p.velocity_608 = [1., 2., 3., 4.]; + let mut packet = Packet::initialized(0.); + produce( + &mut packet, + &controller::State::new([None; 3]), + &curve(), + settings(), + &p, + true, + ) + .unwrap(); + assert_eq!(packet.velocity_0, p.velocity_608); +} + +#[test] +fn geometric_launch_rotation_does_not_turn_x_into_a_fourth_velocity() { + let v = [-2.0225198, 4.1746917, 0.7100913, 0.]; + let rotated = super::math::rotate(v, [0., 1., 0., 0.], 0.); + assert_eq!(rotated, v); + for angle in [-0.2, 0.2] { + let rotated = super::math::rotate(v, [0., 1., 0., 0.], angle); + assert_eq!(rotated[3], 0.); + let (s, c) = crate::trigonometry::sin_cos(angle); + assert!((rotated[0] - (v[0] * c + v[2] * s)).abs() < 1e-6); + assert_eq!(rotated[1], v[1]); + assert!((rotated[2] - (-v[0] * s + v[2] * c)).abs() < 1e-6); + } +} diff --git a/crates/skate-core/src/player/offboard/air_selector.rs b/crates/skate-core/src/player/offboard/air_selector.rs new file mode 100644 index 00000000..25b25c70 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector.rs @@ -0,0 +1,194 @@ +//! Persistent offboard selector72: S3 82D6BD78..82D6EA6C. +//! Separate from onboard Air. World work is submitted/staged by the game host; +//! only explicit consume calls publish results. No physical solver ownership. +mod adjustment; +mod candidates; +mod completion; +pub mod ledge; +mod math; +#[cfg(test)] +mod tests; +use super::{air_launch::Packet, biped_air::recovered}; +use crate::{ + air::trajectory::{Prediction, QueryRequest, QueryResult, Trajectory}, + point_graph::PointGraph, +}; +pub use recovered::selection::Candidate; +pub use recovered::{TrajectoryResult, sampling::SelectorState}; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +pub const DT: f32 = f32::from_bits(0x3c888889); +pub const MAX_CANDIDATES: usize = 16; +pub const MESH_REJECT_MASK: u32 = 0x6000; + +/// Original stock attributes, not incoming material policy. +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// AB0D9EAEBFC584E9 + pub height: f32, + /// 620EEA2E27A371A2 + pub sphere_radius: f32, + /// 1B6E3FF7EC72CDE1 + pub start_index: i32, +} +#[derive(Clone, Copy, Debug)] +pub struct Context { + pub selection_flags_2948: u32, + pub matching_group_2952: i32, + pub up_544: Vector, + pub forward_224: Vector, +} +#[derive(Clone, Debug)] +pub struct Selector { + pub sampling: SelectorState, + pub launch: Packet, + pub candidates: Vec, + pub predictions: Vec, + pub scores: Vec, + pub selected_index: Option, + ///6080 is NOT the shifted/animation-adjusted sampling trajectory8144. + pub selected_candidate: Candidate, + pub offset_8272: Vector, + pub correction_8304: Vector, + pub ledge_normal_8320: Vector, + pub ledge_selected_8496: bool, + pub just_changed_8497: bool, + pub requery_pending_8499: bool, + pub requery_count_8488: i32, + pub requery_position_8352: Vector, + pub requery_normal_8368: Vector, +} +impl Default for Selector { + fn default() -> Self { + Self::new(Packet::initialized(0.)) + } +} +impl Selector { + pub fn new(launch: Packet) -> Self { + //Constructor82D6B870 differs from Reset82D6BD78 at these fields. + let mut sampling = SelectorState::reset_sampling(launch.scalar_100); + sampling.restart_allowed_8493 = false; + sampling.selection.landing_frame_8480 = 0; + Self { + sampling, + launch, + candidates: Vec::new(), + predictions: Vec::new(), + scores: Vec::new(), + selected_index: None, + selected_candidate: Candidate::reset(), + offset_8272: [0.; 4], + correction_8304: [0.; 4], + ledge_normal_8320: math::UP, + ledge_selected_8496: false, + just_changed_8497: false, + requery_pending_8499: false, + requery_count_8488: 0, + requery_position_8352: [0.; 4], + requery_normal_8368: [0.; 4], + } + } + ///82D6BD78 retains packet3904. Reset does not invent a new launch. + pub fn reset(&mut self) { + *self = Self::new(self.launch); + self.sampling = SelectorState::reset_sampling(self.launch.scalar_100); + } + ///82D2EF20 clears only these two selector flags; retained predictions survive. + pub fn exit(&mut self) { + self.sampling.pending_8492 = false; + self.sampling.preinitialized_8494 = false; + } + ///82D6CA58: once per launch. Invalid authored inputs fail, never get clamped + ///into a different candidate count or padded collision request. + pub fn begin_launch( + &mut self, + launch: Packet, + gravity: Vector, + settings: Settings, + ) -> Result, &'static str> { + let (candidates, offset, correction) = candidates::prepare(launch, gravity, settings)?; + self.reset(); + self.launch = launch; + self.sampling.selection.candidate_scalar_100 = launch.scalar_100; + self.sampling + .seed_fallback(launch.position_32, launch.velocity_0, gravity); + self.offset_8272 = offset; + self.correction_8304 = correction; + self.candidates = candidates; + let requests: Vec<_> = self + .candidates + .iter() + .map(|c| request(c.trajectory, settings.sphere_radius)) + .collect(); + self.predictions = requests + .iter() + .map(|&request| Prediction { + request, + result: QueryResult::miss(), + }) + .collect(); + self.scores = vec![0.; requests.len()]; + Ok(requests) + } + pub fn sample( + &mut self, + frame: i32, + timestep: f32, + curve: &PointGraph<8>, + out: &mut TrajectoryResult, + ) { + self.sampling.sample(frame, timestep, curve, out); + } + ///82D6E798. Caller supplies current processed suppression flags. + pub fn begin_requery( + &mut self, + flags_2472: u32, + flags_2488: u32, + radius: f32, + ) -> Result, &'static str> { + if flags_2472 & 0x10000000 != 0 + || flags_2488 & 0x400000 != 0 + || !self.sampling.restart_allowed_8493 + { + return Ok(None); + } + let request = request(self.selected_candidate.trajectory, radius); + validate_request(request)?; + self.sampling.restart_allowed_8493 = false; + self.requery_pending_8499 = true; + //Native requery replaces slot0; it does not replace the winning candidate. + if let Some(slot) = self.predictions.first_mut() { + *slot = Prediction { + request, + result: QueryResult::miss(), + }; + } + Ok(Some(request)) + } +} +pub fn request(mut trajectory: Trajectory, radius: f32) -> QueryRequest { + //82D6CBxx/E844: f1 radius, f2 .25, f3 1.2, f4 duration2. + trajectory.duration = 2.; + QueryRequest { + trajectory, + radius, + start_error: 0.25, + end_error: 1.2, + } +} +pub fn validate_request(q: QueryRequest) -> Result<(), &'static str> { + let t = q.trajectory; + if [t.duration, q.radius, q.start_error, q.end_error] + .iter() + .any(|x| !x.is_finite() || *x <= 0.) + || t.position + .into_iter() + .chain(t.velocity) + .chain(t.acceleration) + .any(|x| !x.is_finite()) + { + Err("Invalid native BipedAir trajectory request") + } else { + Ok(()) + } +} diff --git a/crates/skate-core/src/player/offboard/air_selector/adjustment.rs b/crates/skate-core/src/player/offboard/air_selector/adjustment.rs new file mode 100644 index 00000000..19994f48 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/adjustment.rs @@ -0,0 +1,60 @@ +//!82D6DE08/E018/E178: animation displacement adjusts the sampling arc only. +use super::{DT, Frame, Selector, Vector, math::*}; +impl Selector { + pub fn adjust_animation(&mut self, frame: i32, animation: Vector, axes: Frame, radius: f32) { + self.sampling.adjustment_8336 = scale(axes[2], animation[2]); + self.correction_8304 = scale( + //82D6DF40 is one vmaddfp after the separate forward multiply. + madd( + axes[1], animation[1] - radius, + self.sampling.adjustment_8336, + ), + -1., + ); + self.correction_8304[1] -= 0.08; + let s = &mut self.sampling.selection; + let delta = sub( + sub(s.position_6176, self.correction_8304), + s.trajectory_8144 + .position_at(s.landing_frame_8480 as f32 * DT), + ); + shift(&mut s.trajectory_8144, frame as f32 * DT); + adjust( + &mut s.trajectory_8144, + s.landing_frame_8480.wrapping_sub(frame), + delta, + 0.5, + ); + shift(&mut s.trajectory_8144, frame.wrapping_neg() as f32 * DT); + let t = s.trajectory_8144; + let end = t.position_at(s.landing_frame_8480 as f32 * DT); + if self.launch.up_48[1] < 0.8 + && dot(self.launch.up_48, flatten(t.velocity)) <= 0. + && s.normal_6144[1] > 0.9 + && end[1] - t.position[1] > -0.2 + { + //Runtime constants830BD400/330 are initialized by82F82588/826B0: + //splat(-9.8), [0,-9.8,0,0]; not the launch gravity attribute. + let g = f32::from_bits(0x411ccccd); + let distance = length(sub(t.position, end)); + let square = 2. * distance * reciprocal(g); + let root = if square == 0. { + 0. + } else { + square * crate::physics::board_motion_output::inverse_length_squared(square, 2) + }; + let duration = root * 0.66 + s.landing_frame_8480 as f32 * 0.0056666667; + if duration > 0.2 { + let acceleration = [0., -g, 0., 0.]; + s.trajectory_8144.velocity = sub( + scale(sub(end, t.position), reciprocal(duration)), + scale(scale(acceleration, 0.5), duration), + ); + s.trajectory_8144.acceleration = acceleration; + s.trajectory_8144.duration = -1.; + s.landing_frame_8480 = (duration * 59.999996) as i32; + s.scalar_8392 = s.landing_frame_8480 as f32 * DT; + } + } + } +} diff --git a/crates/skate-core/src/player/offboard/air_selector/candidates.rs b/crates/skate-core/src/player/offboard/air_selector/candidates.rs new file mode 100644 index 00000000..e89c8c1e --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/candidates.rs @@ -0,0 +1,112 @@ +//!82D6BF90; S2 82DBB358 verifies packet/index-shift roles, not S3's new cone. +use super::{Candidate, DT, MAX_CANDIDATES, Packet, Settings, Vector, math::*}; +use crate::air::trajectory::Trajectory; +pub(super) fn prepare( + p: Packet, + gravity: Vector, + s: Settings, +) -> Result<(Vec, Vector, Vector), &'static str> { + let count = p + .kind_108 + .checked_add(p.kind_112) + .ok_or("BipedAir candidate count overflow")? as usize; + if p.kind_108 == 0 + || count > MAX_CANDIDATES + || !s.height.is_finite() + || !s.sphere_radius.is_finite() + || s.sphere_radius <= 0. + { + return Err("Invalid authored BipedAir selector settings"); + } + if [p.scalar_96, p.scalar_100, p.scalar_104] + .iter() + .any(|x| !x.is_finite()) + || [ + p.velocity_0, + p.secondary_velocity_16, + p.position_32, + p.up_48, + p.forward_64, + p.board_position_80, + gravity, + ] + .into_iter() + .flatten() + .any(|x| !x.is_finite()) + { + return Err("Nonfinite BipedAir launch packet"); + } + let initial = scale(UP, -(s.height - s.sphere_radius)); + let mut correction = [0., 0.1, 0., 0.]; + if p.has_board_position_116 { + correction = add( + correction, + sub(p.board_position_80, add(p.position_32, initial)), + ); + } + let offset = add(initial, correction); + let base = sub(p.velocity_0, scale(correction, reciprocal(1.))); + let mut velocities = vec![base; count]; + if count > 1 { + let speed = length(p.velocity_0); + let (axis, right) = if speed >= 0.001 { + let axis = scale(p.velocity_0, reciprocal(speed)); + let mut right = cross(UP, axis); + if length(right) < 0.1 { + right = cross(axis, p.forward_64); + } + (axis, scale(right, reciprocal(length(right)))) + } else { + (UP, scale(p.forward_64, -1.)) + }; + let vertical = cross(axis, right); + //8252D980 is tan; Biped launch already supplies radians. + let tangents = [p.scalar_96.tan(), p.scalar_100.tan(), p.scalar_104.tan()]; + let base_speed = length(base); + let mut minimum = length(flatten(p.velocity_0)); + for i in 1..p.kind_108 as usize { + let angle = (1. - (i as f32 / (p.kind_108 - 1) as f32) * 2.) * std::f32::consts::PI; + let sin = crate::trigonometry::sin(angle); + let cos = crate::trigonometry::cos(angle); + let cone = if cos <= 0. { tangents[1] } else { tangents[2] }; + let v = madd(vertical, cos * cone, madd(right, sin * tangents[0], base)); + velocities[i] = scale(v, base_speed * reciprocal(length(v))); + minimum = minimum.min(length(flatten(velocities[i]))); + } + if p.kind_112 > 0 { + let secondary = flatten(p.secondary_velocity_16); + let n = length(secondary); + let direction = if n > 0.01 { + scale(secondary, reciprocal(n)) + } else { + normalize_or(p.forward_64, [0.; 4]) + }; + let increment = minimum.max(1.).min(4.) / (p.kind_112 + 1) as f32; + let mut speed = 0.; + //Original822F9444=41A95811: fixed native secondary vertical seed. + let square = f32::from_bits(0x41a95811); + let vertical = + square * crate::physics::board_motion_output::inverse_length_squared(square, 2); + for i in p.kind_108 as usize..count { + speed += increment; + velocities[i] = madd(direction, speed, [0., vertical, 0., 0.]); + } + } + } + let mut candidates = Vec::with_capacity(count); + for velocity in velocities { + let mut trajectory = Trajectory { + position: add(p.position_32, offset), + velocity, + acceleration: gravity, + duration: 2., + }; + shift(&mut trajectory, s.start_index as f32 * DT); + super::validate_request(super::request(trajectory, s.sphere_radius))?; + let mut c = Candidate::reset(); + c.trajectory = trajectory; + c.start_frame_112 = s.start_index; + candidates.push(c); + } + Ok((candidates, offset, initial)) +} diff --git a/crates/skate-core/src/player/offboard/air_selector/completion.rs b/crates/skate-core/src/player/offboard/air_selector/completion.rs new file mode 100644 index 00000000..5a720d40 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/completion.rs @@ -0,0 +1,142 @@ +//! S3 82D6D020/CC50/E8A0. Incoming scoring/requery are comparison only. +use super::{Candidate, Context, DT, Selector, ledge, math::*}; +use crate::air::trajectory::{Prediction, QueryResult}; +impl Selector { + ///Populate native landing observations before candidate0's ledge stage. + pub fn observe_launch(&mut self, results: &[QueryResult]) -> Result<(), &'static str> { + if !self.sampling.pending_8492 || results.len() != self.candidates.len() { + return Err("BipedAir launch completion does not match pending batch"); + } + for ((candidate, prediction), &result) in self + .candidates + .iter_mut() + .zip(&mut self.predictions) + .zip(results) + { + prediction.result = result; + if result.valid() { + candidate.contact_position_96 = result.contact_position; + candidate.normal_64 = result.suggested_normal(); + prediction.result.contact_normal = candidate.normal_64; + candidate.contact_velocity_80 = candidate + .trajectory + .velocity_at(result.contact_frame as f32 * DT); + candidate.landing_frame_116 = result.contact_frame; + candidate.valid_120 = true; + } else { + candidate.normal_64 = UP; + candidate.contact_velocity_80 = [0.; 4]; + candidate.landing_frame_116 = 120; + candidate.valid_120 = false; + } + } + Ok(()) + } + ///Native scoring uses the pre-ledge normal/displacement even if candidate0 + ///is subsequently adjusted. Caller passes the original observations. + pub fn select_launch( + &mut self, + context: Context, + original_first: Candidate, + ledge: Option, + ) -> Result { + if !self.sampling.pending_8492 || self.candidates.is_empty() { + return Err("BipedAir select without pending launch"); + } + if let Some(adjustment) = ledge { + adjustment.apply(&mut self.candidates[0]); + self.ledge_normal_8320 = self.candidates[0].normal_64; + self.ledge_selected_8496 = true; + } + for i in 0..self.candidates.len() { + let c = self.candidates[i]; + self.scores[i] = 0.; + if !c.valid_120 { + continue; + } + let original = if i == 0 { original_first } else { c }; + let displacement = sub(original.contact_position_96, original.trajectory.position); + let normal = original.normal_64; + let time = c.landing_frame_116 as f32 * DT; + let mut ledge_bonus = if i == 0 && self.ledge_selected_8496 { + 1. + } else { + 0. + }; + let mut time_penalty = 0.; + if time <= 0.5 { + ledge_bonus = 0.; + time_penalty = -1. - (0.5 - time) * 2.; + } + let center_bonus = if i == 0 { 1. } else { 0. }; + if self.predictions[i].result.surface & 0xf80 == 0x300 { + self.predictions[i].result.contact_normal = context.up_544; + } + let mut normal_penalty = (2.2222223 * ((normal[1] - 0.45) * 5.)).min(0.); + if dot(normal, self.launch.forward_64) > 0. { + normal_penalty = 0.; + } + let mut forward = dot(self.launch.forward_64, displacement).max(1.); + let mut height = displacement[1].max(0.); + if i >= self.launch.kind_108 as usize { + if displacement[1] < 0.4 { + height -= 10000.; + } else { + height *= 8.; + forward = dot(self.launch.secondary_velocity_16, displacement); + } + } + self.scores[i] = ((((0. + forward) + height) + normal_penalty) + center_bonus) + + time_penalty + + ledge_bonus; + } + let index = super::recovered::selection::selected_index(&self.scores) + .ok_or("BipedAir empty candidate batch")?; + self.selected_index = Some(index); + self.selected_candidate = self.candidates[index]; + if self.sampling.commit( + self.selected_candidate, + &mut self.predictions[index], + self.offset_8272, + ) { + self.just_changed_8497 = true; + } + Ok(index) + } + ///82D6E8A0. A miss clears pending only. First hit seeds comparison state; + ///subsequent moved hits update landing observations, not trajectory8144. + pub fn complete_requery(&mut self, prediction: Prediction) -> Result<(), &'static str> { + if !self.requery_pending_8499 { + return Err("BipedAir requery completion without submission"); + } + self.requery_pending_8499 = false; + if let Some(slot) = self.predictions.first_mut() { + *slot = prediction; + } + if !prediction.result.valid() { + return Ok(()); + } + let result = prediction.result; + if self.requery_count_8488 <= 0 { + self.requery_position_8352 = result.contact_position; + self.requery_normal_8368 = result.suggested_normal(); + } else { + let delta = sub(self.requery_position_8352, result.contact_position); + if dot(delta, delta) > 0.0001 { + self.requery_position_8352 = result.contact_position; + self.requery_normal_8368 = result.suggested_normal(); + let s = &mut self.sampling.selection; + s.landing_frame_8480 = result.contact_frame; + s.scalar_8392 = result.contact_frame as f32 * DT; + s.velocity_6160 = prediction + .request + .trajectory + .velocity_at(result.contact_frame as f32 * DT); + s.position_6176 = result.contact_position; + s.normal_6144 = result.suggested_normal(); + } + } + self.requery_count_8488 = self.requery_count_8488.wrapping_add(1); + Ok(()) + } +} diff --git a/crates/skate-core/src/player/offboard/air_selector/ledge.rs b/crates/skate-core/src/player/offboard/air_selector/ledge.rs new file mode 100644 index 00000000..1b294bea --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/ledge.rs @@ -0,0 +1,282 @@ +//! Air-only ledge correction82D6D4D0, filtering82C1F310, six-line82C20728. +//! Candidate0 only. Closest-point/plane formulas read from82E09D28/09DF0. +use super::{Candidate, Context, DT, Vector, math::*}; +use crate::{ + air::trajectory::{Prediction, Trajectory}, + math::Vector3, + player::offboard::ground_query::{Edge, EdgeSearch, Frame, Line, LineHit, QueryContext}, +}; +fn lanes(v: Vector3) -> Vector { + [v.x, v.y, v.z, 0.] +} +fn vec3(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +pub fn search( + candidate: Candidate, + prediction: Prediction, + context: Context, +) -> Option { + if !prediction.result.valid() || prediction.result.contact_frame < 16 { + return None; + } + let collision = prediction.collision_position(); + let (apex, _) = candidate.trajectory.highest_position(); + Some(EdgeSearch { + min: vec3(std::array::from_fn(|i| collision[i].min(apex[i]) - 2.)), + max: vec3(std::array::from_fn(|i| collision[i].max(apex[i]) + 2.)), + frame: Frame { + position: vec3(candidate.trajectory.position), + ..Frame::IDENTITY + }, + context: QueryContext { + selection_flags_2948: context.selection_flags_2948, + matching_id_2952: context.matching_group_2952, + }, + narrow_forward: false, + }) +} +///82C1F310 removes a parallel edge only with native separation, overlap, +/// reference-distance and oriented-height tests. Retains provider order. +pub fn filter_edges(edges: &[Edge], reference: Vector) -> Vec { + let directions: Vec<_> = edges + .iter() + .map(|e| normalize(sub(lanes(e.end), lanes(e.start)))) + .collect(); + edges + .iter() + .enumerate() + .filter_map(|(i, &edge)| { + let start = lanes(edge.start); + let midpoint = scale(add(start, lanes(edge.end)), 0.5); + for (j, other) in edges.iter().enumerate() { + if i == j { + continue; + } + let parallel = cross(directions[i], directions[j]); + if !(dot(parallel, parallel) < 0.0001) { + continue; + } + let delta = sub(lanes(other.start), start); + let off = sub(delta, scale(directions[j], dot(directions[j], delta))); + let distance = dot(off, off); + if !(distance > 0.0001 && distance < 0.010000001) { + continue; + } + let from_mid = sub(lanes(other.start), midpoint); + if !(dot(from_mid, sub(lanes(other.end), midpoint)) < 0.) { + continue; + } + let other_point = add( + midpoint, + sub(from_mid, scale(directions[j], dot(directions[j], from_mid))), + ); + let a = sub(midpoint, reference); + let b = sub(other_point, reference); + let normal = normalize_or(cross(directions[i], cross(UP, directions[i])), [0.; 4]); + let height = dot(delta, normal); + if (height > -0.02 && dot(a, a) > dot(b, b)) || height > 0.02 { + return None; + } + } + Some(edge) + }) + .take(40) + .collect() +} +///82D60C80/B98: exact discriminant branches, larger root for two roots. +/// No generic epsilon-linear fallback, positive clamp, or duration rejection. +pub fn plane_time(t: Trajectory, point: Vector, normal: Vector) -> Option { + let a = dot(normal, scale(t.acceleration, 0.5)); + let b = dot(normal, t.velocity); + let c = dot(normal, t.position) - dot(point, normal); + let discriminant = b * b - (4. * a) * c; + if discriminant < 0. { + return None; + } + let denominator = reciprocal(2. * a); + if discriminant > 0. { + let root = discriminant + * crate::physics::board_motion_output::inverse_length_squared(discriminant, 2); + Some((denominator * (-b + root)).max(denominator * (-b - root))) + } else { + let time = denominator * (-b); + (time > 0.).then_some(time) + } +} +fn closest(point: Vector, edge: Edge) -> Vector { + let start = lanes(edge.start); + let mut delta = sub(lanes(edge.end), start); + let n = length(delta); + if n > f32::from_bits(0x37800000) { + delta = scale(delta, reciprocal(n)); + } + madd(delta, dot(delta, sub(point, start)).min(n).max(0.), start) +} +fn angle(new: Vector, old: Vector, up: Vector) -> f32 { + if !(dot(new, new) * dot(old, old) > f32::from_bits(0x37800000)) { + return 0.; + } + let a = sub(new, scale(up, dot(up, new))); + let b = sub(old, scale(up, dot(up, old))); + let (aa, bb) = (dot(a, a), dot(b, b)); + if !(aa > 0.0001 && bb > 0.0001) { + return 0.; + } + //8296EC98 uses ONE refinement, not the two-refinement generic normalize. + let a = scale( + a, + crate::physics::board_motion_output::inverse_length_squared(aa, 1), + ); + let b = scale( + b, + crate::physics::board_motion_output::inverse_length_squared(bb, 1), + ); + let angle = crate::trigonometry::acos(dot(a, b).max(-1.).min(1.)); + let angle = if dot(cross(a, b), up) < 0. { + std::f32::consts::TAU - angle + } else { + angle + }; + wrap_angle(angle).abs() +} +#[derive(Clone, Copy, Debug)] +pub struct Adjustment { + pub edge: Edge, + pub point: Vector, + pub lowered_trajectory: Trajectory, + pub landing_frame: i32, + pub radius: f32, +} +impl Adjustment { + pub fn apply(self, candidate: &mut Candidate) { + candidate.trajectory = self.lowered_trajectory; + candidate.trajectory.position = + add(candidate.trajectory.position, [0., self.radius, 0., 0.]); + candidate.normal_64 = normalize_or( + cross( + sub(lanes(self.edge.end), lanes(self.edge.start)), + cross(UP, sub(lanes(self.edge.end), lanes(self.edge.start))), + ), + UP, + ); + candidate.contact_position_96 = self.point; + candidate.landing_frame_116 = self.landing_frame; + candidate.contact_velocity_80 = self + .lowered_trajectory + .velocity_at(self.landing_frame as f32 * DT); + candidate.special_121 = true; + } +} +pub fn choose( + candidate: Candidate, + prediction: Prediction, + context: Context, + radius: f32, + edges: &[Edge], +) -> Option { + if !prediction.result.valid() || prediction.result.contact_frame < 16 { + return None; + } + let t = candidate.trajectory; + let mut lowered = t; + lowered.position = add(t.position, [0., -radius, 0., 0.]); + let mut best: Option = None; + for &edge in edges { + let delta = sub(lanes(edge.end), lanes(edge.start)); + let normal = normalize_or(cross(delta, cross(UP, delta)), UP); + let Some(time) = plane_time(t, lanes(edge.start), normal) else { + continue; + }; + let intersection = t.position_at(time); + let point = closest(intersection, edge); + if !(normal[1] > 0.71) { + continue; + } + let horizontal = flatten(sub(point, lowered.position)); + let distance = length(horizontal); + if !(distance > 0.3) { + continue; + } + let direction = scale(horizontal, 1. / distance); + if !(dot( + direction, + normalize_or(flatten(context.forward_224), [0.; 4]), + ) > 0.71) + { + continue; + } + let frame = (time * 59.999996) as i32; + let mut adjusted = lowered; + //Original delta uses the UNLOWERED continuous intersection, not a + //rounded frame sample of the lowered working trajectory (incoming). + adjust(&mut adjusted, frame, sub(point, intersection), 10.); + let velocity_delta = sub(adjusted.velocity, t.velocity); + let height = point[1] - prediction.result.contact_position[1]; + if !(height > -0.05 && dot(velocity_delta, velocity_delta) < 2.25) { + continue; + } + let near_height = height < 0.05; + let maximum = if near_height { 10. } else { 40. }; + if near_height && !(dot(direction, normalize_or(flatten(delta), [0.; 4])) >= 0.71) { + continue; + } + if !(angle(adjusted.velocity, t.velocity, context.up_544) + < maximum * f32::from_bits(0x3c8efa35)) + { + continue; + } + if best.as_ref().is_some_and(|old| old.point[1] > point[1]) { + continue; + } + best = Some(Adjustment { + edge, + point, + lowered_trajectory: adjusted, + landing_frame: frame, + radius, + }); + } + best +} +///82C20530 clears byte64: Air does not append Ground's downward seventh line. +pub fn lines(adjustment: Adjustment, half_wheelbase: f32) -> Option<[Line; 6]> { + let edge = adjustment.edge; + let delta = sub(lanes(edge.end), lanes(edge.start)); + let raw_up = cross(delta, cross(UP, delta)); + if 0.00001 > length(raw_up) { + return None; + } + let up = normalize_or(raw_up, raw_up); + let tangent = normalize_or(delta, delta); + let start = lanes(edge.start); + let projection = madd(tangent, dot(sub(adjustment.point, start), tangent), start); + let center = sub(adjustment.point, sub(adjustment.point, projection)); + let side = cross(up, tangent); + let near = scale(side, 0.09); + let far = scale(side, half_wheelbase); + let short_up = scale(up, 0.04); + let far_up = scale(up, half_wheelbase * f32::from_bits(0x3f87ae14)); + let line = |c, d, radius| Line { + start: vec3(add(c, d)), + end: vec3(sub(c, d)), + radius, + }; + Some([ + line(add(center, near), short_up, 0.), + line(sub(center, near), short_up, 0.), + line(add(center, far), far_up, 0.), + line(sub(center, far), far_up, 0.), + line(center, short_up, 0.), + line(add(center, scale(up, 0.09)), near, 0.001), + ]) +} +///82C20C08: return1 after consuming a submitted batch, even with six misses. +///The general grind-classification output is temporary and discarded by Air. +pub fn consume_lines(hits: &[Option]) -> Result<(), &'static str> { + if hits.len() != 6 { + Err("BipedAir ledge completion requires exactly six lines") + } else { + Ok(()) + } +} diff --git a/crates/skate-core/src/player/offboard/air_selector/math.rs b/crates/skate-core/src/player/offboard/air_selector/math.rs new file mode 100644 index 00000000..349d233f --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/math.rs @@ -0,0 +1,29 @@ +//! Reuse ordinary native-arithmetic leaves; no new physical solver. +use super::{DT, Vector}; +use crate::air::trajectory::Trajectory; +pub(super) use crate::player::wipeout_state::math::{ + add, cross, dot, length, madd, normalize, normalize_or, reciprocal, scale, sub, wrap_angle, +}; +pub(super) const UP: Vector = [0., 1., 0., 0.]; +pub(super) fn flatten(mut v: Vector) -> Vector { + v[1] = 0.; + v +} +pub(super) fn shift(t: &mut Trajectory, time: f32) { + let p = t.position_at(time); + t.velocity = t.velocity_at(time); + t.position = p; +} +///82D609E0: velocity correction only, bounded at the caller's maximum. +pub(super) fn adjust(t: &mut Trajectory, frame: i32, delta: Vector, maximum: f32) { + if frame <= 0 { + return; + } + let correction = scale(delta, reciprocal(frame as f32 * DT)); + let scalar = if dot(correction, correction) > maximum * maximum { + maximum / length(correction) + } else { + 1. + }; + t.velocity = madd(correction, scalar, t.velocity); +} diff --git a/crates/skate-core/src/player/offboard/air_selector/tests.rs b/crates/skate-core/src/player/offboard/air_selector/tests.rs new file mode 100644 index 00000000..262bda56 --- /dev/null +++ b/crates/skate-core/src/player/offboard/air_selector/tests.rs @@ -0,0 +1,275 @@ +use super::math::*; +use super::*; +use crate::math::Vector3; +fn settings() -> Settings { + Settings { + height: 0.9, + sphere_radius: 0.3, + start_index: 3, + } +} +fn packet() -> Packet { + let mut p = Packet::initialized(0.); + p.position_32 = [0., 2., 0., 0.]; + p.velocity_0 = [0., 3., 4., 0.]; + p.forward_64 = [0., 0., 1., 0.]; + p.scalar_100 = 0.2; + p +} +fn context() -> Context { + Context { + selection_flags_2948: 0, + matching_group_2952: -1, + up_544: UP, + forward_224: [0., 0., 1., 0.], + } +} +fn hit(frame: i32, position: Vector) -> QueryResult { + QueryResult { + contact_frame: frame, + contact_time: frame as f32 * DT, + contact_position: position, + surface: 7 << 7, + ..QueryResult::miss() + } +} +fn launched() -> Selector { + let mut s = Selector::default(); + s.begin_launch(packet(), [0., -9.8, 0., 0.], settings()) + .unwrap(); + s +} +fn selected() -> Selector { + let mut s = launched(); + s.observe_launch(&[hit(60, [0., 0., 4., 0.])]).unwrap(); + s.select_launch(context(), s.candidates[0], None).unwrap(); + s +} +fn close(a: Vector, b: Vector) { + for i in 0..4 { + assert!((a[i] - b[i]).abs() < 0.0001, "{a:?} != {b:?}"); + } +} +#[test] +fn constructor_reset_and_exit_have_distinct_native_effects() { + let mut s = Selector::default(); + assert!(!s.sampling.restart_allowed_8493); + assert_eq!(s.sampling.selection.landing_frame_8480, 0); + s.launch = packet(); + s.reset(); + assert_eq!(s.launch, packet()); + assert!(s.sampling.restart_allowed_8493); + assert_eq!(s.sampling.selection.landing_frame_8480, 1000); + let mut s = selected(); + let before = s.predictions.clone(); + let candidate = s.selected_candidate.trajectory; + s.exit(); + assert!(!s.sampling.pending_8492 && !s.sampling.preinitialized_8494); + assert_eq!(s.predictions, before); + assert_eq!(s.selected_candidate.trajectory, candidate); +} +#[test] +fn launch_requests_are_native_valid_and_index_shifted() { + let s = launched(); + let p = packet(); + let trajectory = s.candidates[0].trajectory; + let original = Trajectory { + position: add(p.position_32, s.offset_8272), + velocity: sub(p.velocity_0, [0., 0.1, 0., 0.]), + acceleration: [0., -9.8, 0., 0.], + duration: 2., + }; + close(trajectory.position, original.position_at(3. * DT)); + close(trajectory.velocity, original.velocity_at(3. * DT)); + let q = s.predictions[0].request; + assert_eq!( + (q.trajectory.duration, q.radius, q.start_error, q.end_error), + (2., 0.3, 0.25, 1.2) + ); + assert!(validate_request(q).is_ok()); +} +#[test] +fn invalid_authoring_is_not_capped_or_padded() { + let mut s = Selector::default(); + let mut p = packet(); + p.kind_108 = 17; + assert!(s.begin_launch(p, [0., -9.8, 0., 0.], settings()).is_err()); + p.kind_108 = 0; + assert!(s.begin_launch(p, [0., -9.8, 0., 0.], settings()).is_err()); + let mut bad = settings(); + bad.sphere_radius = 0.; + assert!(s.begin_launch(packet(), [0., -9.8, 0., 0.], bad).is_err()); + assert!(s.candidates.is_empty()); +} +#[test] +fn pending_sample_uses_original_fallback_without_consuming() { + let mut s = launched(); + let fallback = s.sampling.fallback_8208; + let graph = PointGraph { + x: [0.; 8], + y: [1.; 8], + }; + let mut out = TrajectoryResult::default(); + s.sample(10, DT, &graph, &mut out); + close(out.position_272, fallback.position_at(10. * DT)); + assert!(!out.valid_404); + assert_eq!(out.normal_304, UP); + assert!(s.sampling.pending_8492); + assert_eq!(out.scalar_392, packet().scalar_100); +} +#[test] +fn scoring_keeps_first_tie_and_secondary_low_height_penalty() { + let mut p = packet(); + p.kind_108 = 2; + p.kind_112 = 1; + p.secondary_velocity_16 = [0., 0., 100., 0.]; + let mut s = Selector::default(); + s.begin_launch(p, [0., -9.8, 0., 0.], settings()).unwrap(); + let results: Vec<_> = s + .candidates + .iter() + .map(|c| hit(60, add(c.trajectory.position, [0., 0.1, 2., 0.]))) + .collect(); + s.observe_launch(&results).unwrap(); + s.select_launch(context(), s.candidates[0], None).unwrap(); + assert_eq!(s.selected_index, Some(0)); + assert!(s.scores[2] < -9000.); + assert_eq!(recovered::selection::selected_index(&[2., 2., 1.]), Some(0)); +} +#[test] +fn selection_preserves_special_surface_and_unshifted_candidate() { + let mut s = launched(); + s.observe_launch(&[hit(60, [0., 0., 4., 0.])]).unwrap(); + let first = s.candidates[0]; + let edge = crate::player::offboard::ground_query::Edge { + start: Vector3::new(-1., 0., 4.), + end: Vector3::new(1., 0., 4.), + }; + let adjustment = ledge::Adjustment { + edge, + point: [0., 0., 4., 0.], + lowered_trajectory: first.trajectory, + landing_frame: 55, + radius: 0.3, + }; + s.select_launch(context(), first, Some(adjustment)).unwrap(); + assert_eq!(s.sampling.selection.word_8396, 2); + assert_eq!(s.predictions[0].result.contact_frame, 63); + assert_eq!(s.sampling.selection.landing_frame_8480, 55); //S3, not S2 index sum. + assert_eq!( + s.selected_candidate.trajectory.velocity, + first.trajectory.velocity + ); + assert_ne!( + s.sampling.selection.trajectory_8144.position, + s.selected_candidate.trajectory.position + ); +} +#[test] +fn animation_adjusts_sample_only_and_preserves_current_point() { + let mut s = selected(); + let arc = s.sampling.selection.trajectory_8144; + let candidate = s.selected_candidate.trajectory; + let prediction = s.predictions[0]; + let frame = 10; + let before = arc.position_at(frame as f32 * DT); + let axes = [[1., 0., 0., 0.], UP, [0., 0., 1., 0.], [0.; 4]]; + s.adjust_animation(frame, [0., 0.9, 0.3, 0.], axes, 0.3); + close( + s.sampling + .selection + .trajectory_8144 + .position_at(frame as f32 * DT), + before, + ); + assert_eq!(s.selected_candidate.trajectory, candidate); + assert_eq!(s.predictions[0], prediction); +} +#[test] +fn animation_correction_retains_native_fused_up_plus_forward_operation() { + let mut s = selected(); + //Finite orthogonal axes deliberately exercise cancellation. This is an + //arithmetic regression for82D6DF40, not a stock gameplay tuning fixture. + let axes = [[0., 0., 1., 0.], [0.8, 0.6, 0., 0.], + [0.6, -0.8, 0., 0.], [0.; 4]]; + s.adjust_animation(10, [0., 0.6, -0.8, 0.], axes, 0.); + let forward = 0.6_f32 * -0.8; + let expected = -0.8_f32.mul_add(0.6, forward); + assert_ne!(expected, -(0.8_f32 * 0.6 + forward)); + assert_eq!(s.correction_8304[0].to_bits(), expected.to_bits()); +} + +#[test] +fn requery_gates_first_hit_changed_hit_and_miss_retention() { + let mut s = selected(); + let old = s.sampling.selection; + assert!(s.begin_requery(0x10000000, 0, 0.3).unwrap().is_none()); + assert!(s.sampling.restart_allowed_8493); + let request = s.begin_requery(0, 0, 0.3).unwrap().unwrap(); + assert_eq!( + request.trajectory.position, + s.selected_candidate.trajectory.position + ); + s.complete_requery(Prediction { + request, + result: hit(41, [3., 2., 1., 0.]), + }) + .unwrap(); + assert_eq!(s.requery_count_8488, 1); + assert_eq!(s.sampling.selection.position_6176, old.position_6176); + s.sampling.restart_allowed_8493 = true; + let request = s.begin_requery(0, 0, 0.3).unwrap().unwrap(); + s.complete_requery(Prediction { + request, + result: hit(42, [4., 2., 1., 0.]), + }) + .unwrap(); + assert_eq!(s.sampling.selection.landing_frame_8480, 42); + assert_eq!(s.sampling.selection.trajectory_8144, old.trajectory_8144); + let retained = s.sampling.selection; + s.sampling.restart_allowed_8493 = true; + let request = s.begin_requery(0, 0, 0.3).unwrap().unwrap(); + s.complete_requery(Prediction { + request, + result: QueryResult::miss(), + }) + .unwrap(); + assert_eq!(s.requery_count_8488, 2); + assert!(!s.requery_pending_8499); + assert_eq!(s.sampling.selection.position_6176, retained.position_6176); +} +#[test] +fn greatest_plane_root_is_not_limited_to_query_duration() { + let t = Trajectory { + position: [0., 1., 0., 0.], + velocity: [0., 2., 0., 0.], + acceleration: [0., -2., 0., 0.], + duration: 0.1, + }; + let time = ledge::plane_time(t, [0.; 4], UP).unwrap(); + assert!((time - (1. + 2f32.sqrt())).abs() < 0.0001); + let tangent = Trajectory { + position: [0., -1., 0., 0.], + ..t + }; + assert!((ledge::plane_time(tangent, [0.; 4], UP).unwrap() - 1.).abs() < 0.0001); +} +#[test] +fn air_six_lines_consume_even_when_all_miss() { + let edge = crate::player::offboard::ground_query::Edge { + start: Vector3::new(-1., 0., 0.), + end: Vector3::new(1., 0., 0.), + }; + let adjustment = ledge::Adjustment { + edge, + point: [0.; 4], + lowered_trajectory: selected().selected_candidate.trajectory, + landing_frame: 60, + radius: 0.3, + }; + let lines = ledge::lines(adjustment, 0.4).unwrap(); + assert_eq!(lines.len(), 6); + assert_eq!(lines[5].radius, 0.001); + assert!(ledge::consume_lines(&[None; 6]).is_ok()); + assert!(ledge::consume_lines(&[None; 7]).is_err()); +} diff --git a/crates/skate-core/src/player/offboard/biped_air.rs b/crates/skate-core/src/player/offboard/biped_air.rs new file mode 100644 index 00000000..46693699 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air.rs @@ -0,0 +1,158 @@ +//! Pre-migration runtime restored for the Ground build checkpoint. +//! Its known Air lifecycle defects are NOT repaired or validated by this rollback. +//! The separately compiled recovered module is the unfinished replacement. +//! Persistent BipedAir owner storage and lifecycle publication. +//! +//! The native object retains the launch packet, cadence, phase timing and +//! output vectors across Enter/Update/Post/Output/Exit. Keeping that state in +//! one owner prevents the old failure mode where a transient launch result was +//! mistaken for the whole off-board state. +use super::{air_launch::Packet, cadence::BipedCadence}; + +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Input { + pub position_592: Vector, + pub velocity_608: Vector, + pub up_544: Vector, + pub forward_224: Vector, + pub board_position_112: Vector, + pub departure_geometry: Option, + pub velocity_912: Vector, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub previous_state_2504: u32, + pub current_state_2508: u32, + pub current_category_2512: u32, + pub previous_category_2516: u32, + pub raw_x_2692: f32, + pub raw_z_2688: f32, + pub current: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Output { + pub packet: Packet, + pub position_288: Vector, + pub velocity_304: Vector, + pub phase_444: f32, + pub phase_counter_448: f32, + pub update_count_452: u32, + pub landed_404: bool, + pub wipeout_547: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub packet: Packet, + pub cadence: BipedCadence, + pub position_288: Vector, + pub velocity_304: Vector, + pub phase_444: f32, + pub phase_counter_448: f32, + pub update_count_452: u32, + pub landed_404: bool, + pub wipeout_547: bool, + pub active: bool, +} + +impl Default for State { + fn default() -> Self { + Self { + packet: Packet::initialized(0.0), + cadence: BipedCadence::default(), + position_288: [0.0; 4], + velocity_304: [0.0; 4], + phase_444: 0.0, + phase_counter_448: 0.0, + update_count_452: 0, + landed_404: false, + wipeout_547: false, + active: false, + } + } +} + +impl State { + ///82D2E5C0: clear the retained landing/output flags and enter with a + ///fresh launch packet. The caller supplies the actual retained scalar. + pub fn enter(&mut self, retained_packet_scalar_104: f32, position: Vector) { + self.packet = Packet::initialized(retained_packet_scalar_104); + self.position_288 = position; + self.velocity_304 = [0.0; 4]; + self.phase_444 = 0.0; + self.phase_counter_448 = 0.0; + self.update_count_452 = 0; + self.landed_404 = false; + self.wipeout_547 = false; + self.active = true; + } + + ///82D2EF20: the native exit clears the trajectory selector and leaves no + ///live BipedAir publication behind. + pub fn exit(&mut self) { + self.active = false; + self.landed_404 = false; + } + + pub fn update( + &mut self, + input: &Input, + controller: &super::controller::State, + turn_vs_speed: &crate::point_graph::PointGraph<8>, + settings: super::air_launch::Settings, + ) -> Result<(), &'static str> { + if !self.active { + return Ok(()); + } + let processed = super::air_launch::Processed { + board_position_112: input.board_position_112, + forward_224: input.forward_224, + up_544: input.up_544, + position_592: input.position_592, + velocity_608: input.velocity_608, + velocity_912: input.velocity_912, + departure_geometry: input.departure_geometry, + flags_2472: input.flags_2472, + flags_2476: input.flags_2476, + flags_2480: input.flags_2480, + previous_state_2504: input.previous_state_2504, + current_state_2508: input.current_state_2508, + current_category_2512: input.current_category_2512, + previous_category_2516: input.previous_category_2516, + raw_x_2692: input.raw_x_2692, + raw_z_2688: input.raw_z_2688, + }; + super::air_launch::produce( + &mut self.packet, + controller, + turn_vs_speed, + settings, + &processed, + input.current, + )?; + self.position_288 = self.packet.position_32; + self.velocity_304 = self.packet.velocity_0; + self.phase_counter_448 += f32::from_bits(0x3c888889); + self.update_count_452 = self.update_count_452.wrapping_add(1); + Ok(()) + } + + pub fn output(&self) -> Output { + Output { + packet: self.packet, + position_288: self.position_288, + velocity_304: self.velocity_304, + phase_444: self.phase_444, + phase_counter_448: self.phase_counter_448, + update_count_452: self.update_count_452, + landed_404: self.landed_404, + wipeout_547: self.wipeout_547, + } + } +} + +pub mod recovered; diff --git a/crates/skate-core/src/player/offboard/biped_air/collision.rs b/crates/skate-core/src/player/offboard/biped_air/collision.rs new file mode 100644 index 00000000..bd6c9372 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/collision.rs @@ -0,0 +1,124 @@ +//! Air-owned response82D2F748; never changes the shared contact/joint solver. +use super::{DT, Frame, State, Vector, math::*}; +use crate::player::offboard::air_launch::Packet; +#[cfg(test)] +#[path = "collision_tests.rs"] +mod tests; + +#[derive(Clone, Copy, Debug)] +pub struct Response { + ///82D2F93C..950: byte52/value184/requestcounter200. + pub request_52: bool, + pub restart: Option, +} + +///82C1E170: remove only a positive component along the supplied direction. +///Its dot uses the normalized direction, subtraction uses the original one. +fn remove_positive(value: Vector, direction: Vector) -> Vector { + let component = dot(value, normalize_or(direction, ZERO)); + if component > 0. { + sub(value, scale(direction, component)) + } else { + value + } +} + +impl State { + pub fn collision_response( + &mut self, + errors_16288_16304: [Vector; 2], + up_544: Vector, + restart_allowed_8493: bool, + ) -> Response { + //82D2F7A8..888: the sign masks take ABSOLUTE projections and + //absolute component products. This is not ordinary plane rejection. + let vertical = errors_16288_16304.map(|v| dot(v, up_544).abs()); + let residual = std::array::from_fn::<_, 2, _>(|i| { + length(sub( + errors_16288_16304[i], + scale(up_544, vertical[i]).map(f32::abs), + )) + }); + //82D2F918..938 tests BOTH projections and BOTH residual lengths. + let request_52 = vertical.iter().chain(&residual).any(|&v| v > 0.2); + //82D2F810/818/830/838: original permute inserts zeroY and repeatsX inW. + let planar = errors_16288_16304.map(|v| [v[0], 0., v[2], v[0]]); + let sizes = planar.map(length); + let mut response = Response { + request_52, + restart: None, + }; + //82098E1C,82098E28,82098E2C all contain3C23D70A in original S3. + if !(sizes[0] > 0.01 || sizes[1] > 0.01) { + return response; + } + if self.flags_544_550[6] { + self.flags_544_550[3] = true; //82D2FA10..20 + return response; + } + //82D2FA24..40: strict greater; a tie chooses16304. + let normal = normalize(if sizes[0] > sizes[1] { + planar[0] + } else { + planar[1] + }); + let velocity = self.result.velocity_288; + if (sizes[0] > 0.01 || sizes[1] > 0.01) && dot(normal, velocity) < -10. { + self.flags_544_550[3] = true; //82D2FA70..FB44 + } + if !(dot(velocity, normal) < 0.) { + return response; + } //82D2FB4C..5C + let above_root = + self.result.scalar_392 > self.frame_208[3][1] && self.result.normal_304[1] > 0.7; //82D2FB64..BF0 + if restart_allowed_8493 && !above_root { + response.restart = Some(normal); //82D2FBF8..FC14 + } + response + } + + ///82D2FC18..FD58: caller first produces the complete native launch packet. + ///Secondary velocity and all other producer fields retain their own values. + pub fn restart_packet( + &mut self, + mut packet: Packet, + normal: Vector, + position_592: Vector, + up_544: Vector, + foot_height: f32, + ) -> Packet { + self.flags_544_550[2] = true; + //The source computes two normalized directions; the second explicitly + //zerosY. Both are planar here becauseF810..838 already removedY. + let horizontal = normal; + let first = remove_positive(self.result.velocity_288, scale(horizontal, -1.)); + let second = remove_positive(first, scale(normal, -1.)); + packet.velocity_0 = madd(normal, 1., second); //82D2FCC4 + packet.board_position_80 = madd(up_544, foot_height, position_592); //FD44 + packet.position_32 = madd(packet.velocity_0, DT, position_592); //FD48 + packet.has_board_position_116 = true; //FD30 + self.restart_normal_528 = normal; //FD28 + packet + } + + ///Only after the host successfully submits the restart82D6CA58. + pub fn finish_restart(&mut self, effective_animation_frame: Frame, normal: Vector) { + self.frame_452 = 0; //82D2FD5C + self.flags_544_550[0] = false; + self.flags_544_550[1] = false; + self.frame_80 = effective_animation_frame; //82D2FD70..DA4 + self.initial_up_480 = normal; //82D2FDA8, NOT frame80's up + } + + ///82D2F490..534 adjusts the current sample after a restart; it does NOT + ///resample or overwrite the persistent selector's new trajectory. + pub fn correct_restarted_sample(&mut self) { + if self.flags_544_550[2] { + let step = scale(self.result.velocity_288, DT); + self.result.position_272 = add( + sub(self.result.position_272, step), + remove_positive(step, scale(self.restart_normal_528, -1.)), + ); + } + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/collision_tests.rs b/crates/skate-core/src/player/offboard/biped_air/collision_tests.rs new file mode 100644 index 00000000..f6304fe5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/collision_tests.rs @@ -0,0 +1,29 @@ +use super::*; +#[test] +fn vertical_error_requests_bail_without_planar_restart() { + let mut state = State::default(); + state.result.velocity_288 = [0., -3., 0., 0.]; + let response = state.collision_response([[0., 0.21, 0., 0.], [0.; 4]], [0., 1., 0., 0.], true); + assert!(response.request_52); + assert!(response.restart.is_none()); +} +#[test] +fn restart_changes_launch_and_current_sample_without_rewriting_secondary_velocity() { + let mut state = State::default(); + state.result.velocity_288 = [-3., 1., 0., 0.]; + state.result.position_272 = [2., 3., 4., 0.]; + let response = state.collision_response([[0.02, 0., 0., 0.], [0.; 4]], [0., 1., 0., 0.], true); + let normal = response.restart.unwrap(); + let mut packet = Packet::initialized(0.); + packet.secondary_velocity_16 = [5., 6., 7., 0.]; + let result = state.restart_packet(packet, normal, [2., 3., 4., 0.], [0., 1., 0., 0.], -0.8); + assert_eq!(result.secondary_velocity_16, [5., 6., 7., 0.]); + for (actual, expected) in result.velocity_0[..3].iter().zip([1., 1., 0.]) { + assert!((actual - expected).abs() < 1e-5); + } + assert!(result.has_board_position_116); + let retained = state.result.velocity_288; + state.correct_restarted_sample(); + assert_eq!(state.result.velocity_288, retained); + assert!((state.result.position_272[0] - (2. + 3. * DT)).abs() < 1e-6); +} diff --git a/crates/skate-core/src/player/offboard/biped_air/feet.rs b/crates/skate-core/src/player/offboard/biped_air/feet.rs new file mode 100644 index 00000000..0d0506d6 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/feet.rs @@ -0,0 +1,203 @@ +//! Native foot placement82D773F8/82D77558/82D77AE8. The persistent storage is +//! shared with the original state56 manager; all contacts come from Processed. +use super::{Frame, Vector as V, math::{dot, length, limit_angle, madd, scale, sub}}; +use crate::player::offboard::board_possession::manager::State; +use crate::player::wipeout_state::math::{normalize_or as unit, reciprocal}; +use crate::physics::skeleton_animation_record::transform_point; +pub struct Line { + pub position: V, + pub normal: V, + pub surface: u32, + pub valid: bool, +} +pub struct Input { + pub lines: [Line; 2], + pub local_foot_pairs: [[V; 2]; 2], + pub world_foot_pairs: [[V; 2]; 2], + pub root: Frame, + pub inverse_root: Frame, + pub effective_root: Frame, + pub position: V, + pub velocity: V, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub state: u32, +} +pub struct Target { + pub position: V, + pub world: bool, + pub blend: f32, + pub normal: Option, +} +///Caller clears both100 timers and308/312/316 for the Air-specific entry. +///GeneralUpdate consumes these targets only when2484 sign bit is clear. +pub fn update(s: &mut State, i: &Input) -> [Target; 2] { + for (index, (foot, line)) in s.hands.iter_mut().zip(&i.lines).enumerate() { + foot.vectors_0_to_64[1] = i.world_foot_pairs[index][0]; + foot.vectors_0_to_64[3] = line.position; + foot.vectors_0_to_64[4] = line.normal; + foot.word_80 = line.surface; + foot.flag_84 = line.valid; + let side = index ^ usize::from(i.flags_2476 & 4 != 0); + foot.flags_104_to_107[2] = i.flags_2484 & (1 << (6 + side)) != 0; + foot.flags_104_to_107[3] = i.flags_2484 & (1 << (2 + side)) != 0; + } + if s.words_308_to_316[0] == 0 { + s.vectors_224_to_272[2] = i.effective_root[3]; + s.flags_304_to_307[3] = true; + } + //82D783A8: evaluate the old support mode before82D77CE0 replaces it. + for index in 0..2 { + let foot = &mut s.hands[index]; + let low = s.word_300 != 3 && i.local_foot_pairs[index].iter().all(|p| p[1] < 0.09); + foot.flags_104_to_107[0] = low; + let supported = if foot.flag_84 { + let normal = clamp_normal(foot.vectors_0_to_64[4]); + foot.vectors_0_to_64[2] = normal; + let distances = + i.world_foot_pairs[index].map(|p| dot(normal, sub(p, foot.vectors_0_to_64[3]))); + let distance = distances[0].max(distances[1]); + distance < 0.09 || (low && distance < 0.2) + } else { + foot.vectors_0_to_64[2] = [0., 1., 0., 0.]; + low + }; + foot.flags_104_to_107[1] = supported; + } + let mirrored = i.flags_2476 & 4 != 0; + let forced_left = i.flags_2480 & (if mirrored { 1 << 5 } else { 1 << 6 }) != 0; + let forced_right = i.flags_2480 & (if mirrored { 1 << 6 } else { 1 << 5 }) != 0; + if forced_left { + s.hands[0].flags_104_to_107[1] = true; + s.hands[1].flags_104_to_107[1] = false; + } else if forced_right { + s.hands[0].flags_104_to_107[1] = false; + s.hands[1].flags_104_to_107[1] = true; + } + s.scalars_288_to_296[1] += f32::from_bits(0x3c888889); + let support = if i.state == 501 { + 3 + } else if s.flags_304_to_307[0] { + match s.hands.map(|h| h.flags_104_to_107[1]) { + [true, false] => 0, + [false, true] => 1, + _ => 2, + } + } else { + 2 + }; + if support != s.word_300 { + s.word_300 = support; + s.scalars_288_to_296[1] = 0.; + s.vectors_224_to_272[3] = if support < 2 { + i.world_foot_pairs[support as usize][0] + } else { + [0.; 4] + }; + } + for index in 0..2 { + let support_index = index as u32; + let foot = s.hands[index]; + let mut desired = foot.vectors_0_to_64[1]; + if s.word_300 == support_index { + let mut flat = sub(s.vectors_224_to_272[3], desired); + flat[1] = 0.; + if dot(flat, flat) < f32::from_bits(0x3bd1b717) { + desired = s.vectors_224_to_272[3]; + } else { + let old = s.vectors_224_to_272[3]; + let distance = length(flat); + desired = madd(flat, 0.08 / distance, desired); + desired[1] = old[1]; + s.vectors_224_to_272[3] = desired; + } + s.hands[index].vectors_0_to_64[0] = desired; + } else { + if foot.flag_84 { + desired[1] = foot_height( + s, + i, + foot.vectors_0_to_64[3][1], + desired[1] - foot.scalars_96_100[0].max(0.), + ); + } + let time = (foot.scalars_96_100[1] - f32::from_bits(0x3c888889)).max(0.); + s.hands[index].scalars_96_100[1] = time; + let weight = 1. - time * 10.; + s.hands[index].vectors_0_to_64[0] = + madd(desired, weight, scale(foot.vectors_0_to_64[0], 1. - weight)); + } + } + let targets = std::array::from_fn(|index| { + let foot = &mut s.hands[index]; + let world = s.word_300 == index as u32; + if world && i.flags_2484 & 0x80000000 == 0 { + foot.scalars_96_100[1] = 0.1; + } + Target { + position: if world { + foot.vectors_0_to_64[0] + } else { + transform_point(&i.inverse_root, foot.vectors_0_to_64[0]) + }, + world, + blend: if world { 1. } else { 0.3 }, + normal: (foot.flags_104_to_107[1] && foot.flag_84).then_some(foot.vectors_0_to_64[2]), + } + }); + s.scalars_288_to_296[0] = i.root[3][1]; + s.scalars_288_to_296[2] = height_correction(s, i); + targets +} +fn foot_height(s: &State, i: &Input, ground: f32, desired: f32) -> f32 { + let base = ground + 0.02; + let proposed = (desired - s.scalars_288_to_296[0]) + ground; + let limited = proposed.min(base + 0.08).max(base); + let output = desired.max(limited); + let ceiling = i.velocity[1].mul_add(f32::from_bits(0x3c888889), i.position[1]) - 0.46; + if output - desired > 0.4 || output > ceiling { + desired + } else { + output + } +} +fn clamp_normal(mut normal: V) -> V { + if normal[1] >= 0.9 { + return normal; + } + normal[1] = 0.; + //82D78260 does NOT safe-normalize this flattened normal. + //Verified image822F8BD8 =3E428F5F; retain the unguarded reciprocal. + normal = scale(normal, reciprocal(length(normal)) * f32::from_bits(0x3e428f5f).sqrt()); + normal[1] = 0.9; + normal +} +fn height_correction(s: &mut State, i: &Input) -> f32 { + if s.hands.iter().all(|f| !f.flag_84) { + return 0.; + } + let mut highest = f32::from_bits(0x501502f9); + for foot in &mut s.hands { + foot.scalars_96_100[0] = 0.; + if !foot.flag_84 { + continue; + } + if foot.flags_104_to_107[0] { + let normal = foot.vectors_0_to_64[4]; + let side = i.effective_root[0]; + let normal = unit(sub(normal, scale(side, dot(side, normal))), [0.; 4]); + foot.scalars_96_100[0] = dot(normal, i.effective_root[2]) * 0.2; + } + highest = highest.min(foot.vectors_0_to_64[3][1] + foot.scalars_96_100[0]); + } + s.scalars_288_to_296[0] - highest +} +///82BEDB78 normal setter, shared by both feet after placement. +pub fn set_normal(target: &mut crate::animation::foot_ik::external::ExternalTarget, normal: V) { + if target.normal_blend > 0. { + target.normal = limit_angle(normal, target.normal, 0.1); + } + target.normal_set = true; + target.normal_blend = (target.normal_blend + 0.2).min(1.); +} diff --git a/crates/skate-core/src/player/offboard/biped_air/height.rs b/crates/skate-core/src/player/offboard/biped_air/height.rs new file mode 100644 index 00000000..27af7d71 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/height.rs @@ -0,0 +1,43 @@ +//! Original TU3 82D2FF50 and 82D2FDD0, recovered from disassembly. +use super::{DT, Vector}; +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + a[2].mul_add(b[2], a[1].mul_add(b[1], a[0] * b[0])) +} +pub(super) fn sub(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn madd(a: Vector, b: f32, c: Vector) -> Vector { + core::array::from_fn(|i| a[i].mul_add(b, c[i])) +} +pub(super) fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +///Translations returned by82BE3220 for bones15/19, in that order. +pub fn projected_height(bone15: Vector, bone19: Vector, reference: Vector, up: Vector) -> f32 { + let a = dot(up, sub(bone15, reference)); + let b = dot(up, sub(bone19, reference)); + select(a - b, b, a) +} +#[derive(Clone, Copy, Debug)] +pub struct HeightInput { + pub bone15: Vector, + pub bone19: Vector, + ///82BE3170(skeleton,1) translation. + pub bone1: Vector, + pub up_544: Vector, + pub velocity_608: Vector, +} +impl super::State { + pub fn correct_height(&mut self, input: HeightInput) { + let height = projected_height(input.bone15, input.bone19, + self.result.position_272, input.up_544); + let limit = (dot(input.up_544, input.velocity_608) * DT).abs() + 0.05; + let delta = (height + 0.79) - self.height_432; + let lower = select(-limit - delta, -limit, delta); + self.height_432 += select(limit - lower, lower, limit); + self.body_target_416 = madd(input.up_544, self.height_432, self.result.position_272); + let predicted = madd(self.result.velocity_288, DT, input.bone1); + let lowered = madd(input.up_544, -0.4, predicted); + self.body_offset_436 = dot(sub(lowered, self.body_target_416), input.up_544); + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/orientation.rs b/crates/skate-core/src/player/offboard/biped_air/orientation.rs new file mode 100644 index 00000000..d811eb5c --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/orientation.rs @@ -0,0 +1,107 @@ +//! Original TU3 82D2EF38 first-contact orientation and 82D2E968/82D2E7C8. +//! S2 82D94158/82D88320 instead adjusts body spin; that algorithm is not used. +use super::{State, Vector, math::*}; +use crate::player::wipeout_state::{math::limit_length, orientation::projected_angle}; + +impl State { + /// Return the actual adjustment to submit to selector82D6DE08. This does + /// not invent a completion or change selector state before its host call. + pub fn animation_adjustment(&mut self, value_2880: Vector) -> Option { + if self.flags_544_550[1] { + if value_2880[1] > 0.1 { + let delta = sub(value_2880, self.adjustment_496); + if dot(delta, delta) > 0.001 { + self.adjustment_496 = add(self.adjustment_496, limit_length(delta, 0.2)); + return Some(self.adjustment_496); + } + } + } else if self.flags_544_550[0] && value_2880[1] > 0.1 { + self.flags_544_550[1] = true; + self.adjustment_496 = value_2880; + return Some(value_2880); + } + None + } + + /// Called immediately after selector sampling; frames remain animation + /// targets. Only the shared physical solver advances the physical bodies. + pub fn orient_sample(&mut self, start_angle_2936: f32, flags_2476: u32) { + if !self.flags_544_550[0] && self.result.valid_404 { + self.frame_80 = self.frame_208; + let mut up = UP; + if self.result.normal_304[1] > 0.85 { + up = limit_angle( + normalize_or(add(self.result.normal_304, UP), self.frame_80[1]), + self.frame_80[1], + (self.result.time_remaining_384 * 180.) * RADIANS, + ); + } else if self.frame_80[1][1] < 0.71 { + self.flags_544_550[5] = true; + } + self.landing_orientation(self.result.contact_velocity_320, up, + self.result.time_remaining_384, start_angle_2936, flags_2476); + self.flags_544_550[0] = true; + self.flags_544_550[2] = false; + } + self.update_times(); + self.frame_208 = crate::animation::foot_ik::interpolate_native( + &self.frame_80, &self.frame_144, self.blend_440).0; + // The shared interpolation helper preserves native SIMD scratch lanes. + // Air publishes a geometric frame, not a register image. Convert here, + // before COM/root construction and the eventual Ground placement. + // Deliberately leave the shared helper and all XYZ calculations intact. + self.frame_208 = self.frame_208.map(|v| [v[0], v[1], v[2], 0.]); + } + + fn landing_orientation(&mut self, velocity: Vector, up: Vector, remaining: f32, + start_angle: f32, flags_2476: u32) { + if self.flags_544_550[6] { + self.vector_512 = self.frame_80[2]; + let horizontal = flatten(velocity); + if dot(horizontal, horizontal) > 0.01 { + let angle = if flags_2476 & 4 != 0 { -start_angle } else { start_angle }; + self.vector_512 = normalize(rotate(normalize(horizontal), UP, -angle)); + } + self.frame_144 = super::super::super::controller::build_frame(up, self.vector_512); + return; + } + let degrees = (wrap_angle(projected_angle(self.frame_208[2], velocity, up)) + * f32::from_bits(0x4265_2ee1)).abs(); + if self.flags_544_550[2] { + self.frame_80 = self.frame_208; + if degrees > 90. { return; } + } + self.frame_144 = self.frame_208; + if remaining < 0.033 { + self.frame_80 = self.frame_208; + return; + } + let tangent = sub(velocity, scale(up, dot(velocity, up))); + let mut right = if length(tangent) > 1. { + normalize_or(cross(up, velocity), self.frame_144[0]) + } else { + normalize_or(cross(up, self.frame_208[2]), self.frame_144[0]) + }; + if length(tangent) > 1. && degrees > 90. && degrees / remaining > 540. { + self.flags_544_550[5] = true; + right = scale(right, -1.); + } + self.frame_144[0] = right; + self.frame_144[1] = up; + self.frame_144[2] = normalize_or(cross(right, up), self.frame_144[2]); + } + + ///82D2F698..6CC: the larger early assist decays over the source interval. + pub fn landing_assist_limit(elapsed_2664: f32) -> f32 { + let time = select(-(elapsed_2664 - 0.034), 0., elapsed_2664 - 0.034); + let blend = clamp(-(time * 6.25 - 1.), 0., 1.); + (1. - blend) * 0.5 + blend * 3.5 + } + + pub fn finish_landing_latch(&mut self) { + if self.time_remaining_444 <= 0. && self.result.valid_404 + && self.result.normal_304[1] > 0.1 { + self.flags_544_550[4] = false; + } + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/post.rs b/crates/skate-core/src/player/offboard/biped_air/post.rs new file mode 100644 index 00000000..4392ee32 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/post.rs @@ -0,0 +1,70 @@ +//! Air Post82D2FFF8 and its two approved offboard checks; canonical request storage. +use super::{DT, Vector, height::dot}; +use crate::player::wipeout::{Frame, Mode, Requests}; +#[path = "post/checks.rs"] +pub mod checks; +pub use checks::Settings; +#[cfg(test)] +#[path = "post/tests.rs"] +mod tests; +#[derive(Clone, Copy, Debug)] +pub struct PostInput { + pub flags_2484: u32, + pub state_timer_2664: f32, + pub forward_224: Vector, + pub side_192: Vector, + pub input_2708: f32, + pub input_2704: f32, +} +impl super::State { + ///Caller synchronizes the canonical landing manager immediately before this. + pub fn post_physics( + &self, + input: PostInput, + settings: &Settings, + frame: &Frame, + mode: Mode, + root_velocity: Vector, + requests: &mut Requests, + ) { + if input.flags_2484 & 2 != 0 { + checks::skeleton_air(requests, settings, mode, frame); + } + if !self.result.valid_404 && input.state_timer_2664 > f32::from_bits(0x3ff33333) { + requests.request(26, 0.); + } + if self.flags_544_550[3] { + requests.request(30, 0.); + } + checks::offboard_air(requests, settings, mode, frame, root_velocity); + if input.flags_2484 & 0x400 != 0 && self.time_remaining_444 > DT { + requests.request(31, 0.); + } + if self.flags_544_550[5] && self.result.velocity_288[1] < 0.0 { + requests.request(31, 0.); + } + if self.result.valid_404 + && -dot(self.result.normal_304, self.result.contact_velocity_320) + > f32::from_bits(0x41473333) + && self.time_remaining_444 < f32::from_bits(0x3e23d70a) + { + requests.request(33, 0.); + } + if self.time_remaining_444 <= 0.0 && !self.flags_544_550[6] { + let forward = dot(input.forward_224, self.result.contact_velocity_320); + let side = dot(input.side_192, self.result.contact_velocity_320); + //82D30248 compares forward again. The decompiler aliases both. + if forward > 20.0 || side > 4.0 || forward < -2.0 { + requests.request(31, 0.); + } + } + if self.time_remaining_444 < 0.0 && self.flags_544_550[4] { + requests.request(31, 0.); + } + if self.time_remaining_444 > 4.0 + && (input.input_2708.abs() > 0.01 || input.input_2704.abs() > 0.01) + { + requests.request(26, 0.); + } + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/post/checks.rs b/crates/skate-core/src/player/offboard/biped_air/post/checks.rs new file mode 100644 index 00000000..f7ee9f49 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/post/checks.rs @@ -0,0 +1,51 @@ +//! S3 82D8FEE8/82D90000; S2 82DD8C90/82DD8EF0 cross-check. +//! These are Air's called checks, not replacements for general wipeout. +use super::{Frame, Mode, Requests, Vector, dot}; +use crate::player::wipeout::regional_force; +#[derive(Clone, Copy, Debug)] +pub struct Thresholds { + pub squash: f32, + pub displacement: f32, + pub body_contact: f32, + pub arm_contact: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub skeleton_air: Thresholds, + pub offboard_air: Thresholds, + pub offboard_min_speed: f32, +} +fn collision(requests: &mut Requests, thresholds: Thresholds, mode: Mode, frame: &Frame) { + //FF48/9009C: squash is first and gated by the actual physics mode. + if mode.check_squash && frame.maximum_pose_error > thresholds.squash { + requests.request(18, 0.); + } else if dot(frame.pose_error, frame.pose_error) + <= thresholds.displacement * thresholds.displacement + { + //FFA4/900EC uses <=; retain its unordered-comparison behavior. + if regional_force(frame, thresholds.body_contact, thresholds.arm_contact) { + requests.request(0, 0.); + } + } else { + requests.request(1, 0.); + } +} +pub fn skeleton_air(requests: &mut Requests, settings: &Settings, mode: Mode, frame: &Frame) { + //82D8FEE8 has neither a mode write nor a speed gate. + collision(requests, settings.skeleton_air, mode, frame); +} +pub fn offboard_air( + requests: &mut Requests, + settings: &Settings, + mode: Mode, + frame: &Frame, + root_velocity: Vector, +) { + //90018..20 occurs even below the speed threshold. + requests.mode = 4; + //90028..5C reads Skeleton16336, not COM velocity. + if dot(root_velocity, root_velocity) > settings.offboard_min_speed * settings.offboard_min_speed + { + collision(requests, settings.offboard_air, mode, frame); + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/post/tests.rs b/crates/skate-core/src/player/offboard/biped_air/post/tests.rs new file mode 100644 index 00000000..dcdc7a44 --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/post/tests.rs @@ -0,0 +1,155 @@ +use super::{checks::*, *}; +use crate::{ + physics::skeleton_animation_record::IDENTITY, player::offboard::biped_air::recovered::State, +}; +fn frame() -> Frame { + Frame { + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + category: 500, + timestep: DT, + time_on_ground: 0., + speed: 0., + animation_up: [0., 1., 0., 0.], + landing_angle: 0., + deck_velocity: [0.; 4], + com_velocity: [0.; 4], + jump_fix_frames: 0, + deck: IDENTITY, + input_board: IDENTITY, + world_to_animation: IDENTITY, + closing_velocity: [0.; 4], + board_material_flags: 0, + board_contact: false, + wheel_contact: false, + board_contact_normal: [0.; 4], + opposing_contact: 0., + regions_force: [0.; 8], + maximum_skater_force: 0., + vehicle_force: 0., + group_8: false, + conflicting: false, + compliant: false, + highest_normal: [0.; 4], + pose_error: [0.; 4], + maximum_pose_error: 0., + flip_active: false, + flip_requested_speed: 0., + system_up_y: 1., + grind_selected: false, + grind_normal_valid: false, + grind_normal: [0.; 4], + } +} +fn mode() -> Mode { + Mode { + check_squash: true, + check_bad_landing: false, + ground_xz: 0., + bad_landing_scale: 0., + } +} +fn settings() -> Settings { + Settings { + skeleton_air: Thresholds { + squash: 2., + displacement: 3., + body_contact: 4., + arm_contact: 5., + }, + offboard_air: Thresholds { + squash: 6., + displacement: 7., + body_contact: 8., + arm_contact: 9., + }, + offboard_min_speed: 10., + } +} +#[test] +fn offboard_speed_is_strict_root_velocity_and_mode_always_changes() { + let mut f = frame(); + f.maximum_pose_error = 100.; + f.com_velocity = [100.; 4]; + let mut r = Requests::new(); + offboard_air(&mut r, &settings(), mode(), &f, [10., 0., 0., 900.]); + assert_eq!(r.mode, 4); + assert_eq!(r.count, 0); //Boundary equality, COM and W do not trigger. + offboard_air(&mut r, &settings(), mode(), &f, [10.01, 0., 0., 0.]); + assert_eq!(r.count, 1); + assert!(r.reasons[18]); +} +#[test] +fn squash_displacement_and_force_are_exclusive() { + let mut f = frame(); + f.maximum_pose_error = 10.; + f.pose_error = [10., 0., 0., 0.]; + f.regions_force = [20.; 8]; + let mut r = Requests::new(); + skeleton_air(&mut r, &settings(), mode(), &f); + assert_eq!(r.count, 1); + assert!(r.reasons[18] && !r.reasons[1] && !r.reasons[0]); + let mut m = mode(); + m.check_squash = false; + skeleton_air(&mut r, &settings(), m, &f); + assert_eq!(r.count, 2); + assert!(r.reasons[1] && !r.reasons[0]); + f.pose_error = [3., 0., 0., 100.]; //Equal displacement goes to regional forces. + skeleton_air(&mut r, &settings(), m, &f); + assert_eq!(r.count, 3); + assert!(r.reasons[0]); + assert_eq!(r.mode, 0); //8FEE8 never writes mode. +} +#[test] +fn offboard_uses_its_own_body_limit_and_stock_ob_arm_limit() { + let mut f = frame(); + let mut r = Requests::new(); + f.regions_force[2] = 9.; + offboard_air(&mut r, &settings(), mode(), &f, [11., 0., 0., 0.]); + assert_eq!(r.count, 0); + f.regions_force[2] = 9.01; + offboard_air(&mut r, &settings(), mode(), &f, [11., 0., 0., 0.]); + assert_eq!(r.count, 1); + assert!(r.reasons[0]); +} +#[test] +fn unordered_displacement_retains_native_else_branch() { + let mut f = frame(); + f.pose_error[0] = f32::NAN; + let mut r = Requests::new(); + skeleton_air(&mut r, &settings(), mode(), &f); + assert!(r.reasons[1]); + assert_eq!(r.count, 1); +} +#[test] +fn air_post_does_not_deduplicate_repeated_request_31() { + let mut s = State::default(); + s.result.valid_404 = true; + s.result.velocity_288[1] = -1.; + s.result.contact_velocity_320 = [0., 0., 21., 0.]; + s.flags_544_550[5] = true; + s.time_remaining_444 = -1.; + let mut r = Requests::new(); + s.post_physics( + PostInput { + flags_2484: 0, + state_timer_2664: 0., + forward_224: [0., 0., 1., 0.], + side_192: [1., 0., 0., 0.], + input_2708: 0., + input_2704: 0., + }, + &settings(), + &frame(), + mode(), + [0.; 4], + &mut r, + ); + assert_eq!(r.count, 3); //Downward flag549, planar overspeed, expired latch548. + assert!(r.reasons[31]); + assert_eq!(r.reasons.iter().filter(|&&v| v).count(), 1); + assert_eq!(r.mode, 4); +} diff --git a/crates/skate-core/src/player/offboard/biped_air/recovered.rs b/crates/skate-core/src/player/offboard/biped_air/recovered.rs new file mode 100644 index 00000000..1669473f --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/recovered.rs @@ -0,0 +1,233 @@ +//! BipedAir501 state from original TU3 IDA, build SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Native result offsets identify fields;288 is velocity,304 is a normal. +#[path = "height.rs"] +pub mod height; +#[path = "post.rs"] +pub mod post; +#[path = "result.rs"] +pub mod result; +#[path = "sampling.rs"] +pub mod sampling; +#[path = "selection.rs"] +pub mod selection; +pub use result::{OffBoardOutput, TrajectoryResult}; +#[path = "collision.rs"] +pub mod collision; +#[path = "feet.rs"] +pub mod feet; +use super::super::air_launch::math; +#[path = "orientation.rs"] +mod orientation; +use super::super::{air_launch, cadence::BipedCadence}; +use height::{dot, select, sub}; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +pub const DT: f32 = f32::from_bits(0x3c888889); +const IDENTITY: Frame = [ + [1., 0., 0., 0.], + [0., 1., 0., 0.], + [0., 0., 1., 0.], + [0.; 4], +]; + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub frame_80: Frame, + pub frame_144: Frame, + pub frame_208: Frame, + pub result: TrajectoryResult, + pub body_target_416: Vector, + pub height_432: f32, + pub body_offset_436: f32, + pub blend_440: f32, + pub time_remaining_444: f32, + pub duration_448: f32, + pub frame_452: i32, + pub local_contact_464: Vector, + pub initial_up_480: Vector, + pub adjustment_496: Vector, + pub vector_512: Vector, + pub restart_normal_528: Vector, + pub flags_544_550: [bool; 7], + ///Host publication of the shared Biped cadence; never reset on Air entry. + pub cadence: BipedCadence, + pub active: bool, +} +impl Default for State { + fn default() -> Self { + let mut state = Self { + frame_80: IDENTITY, + frame_144: IDENTITY, + frame_208: IDENTITY, + result: TrajectoryResult::default(), + body_target_416: [0.; 4], + height_432: 0., + body_offset_436: 0., + blend_440: 0., + time_remaining_444: 0., + duration_448: -1., + frame_452: 0, + local_contact_464: [0.; 4], + initial_up_480: [0.; 4], + adjustment_496: [0.; 4], + vector_512: [0.; 4], + restart_normal_528: [0.; 4], + flags_544_550: [false; 7], + cadence: BipedCadence::default(), + active: false, + }; + state.reset(); + state + } +} + +#[derive(Clone, Copy, Debug)] +pub struct EnterInput { + pub animation_frame: Frame, + pub body_position_15872: Vector, + pub body_position_15936: Vector, + pub position_592: Vector, + pub up_544: Vector, + pub flags_2484: u32, +} + +impl State { + ///82D2EDD8, including selective result reset and byte548=true. + pub fn reset(&mut self) { + self.flags_544_550 = [false, false, false, false, true, false, false]; + self.height_432 = 0.; + self.body_offset_436 = 0.; + self.blend_440 = 0.; + self.time_remaining_444 = 0.; + self.duration_448 = -1.; + self.frame_452 = 0; + self.body_target_416 = [0.; 4]; + self.result.reset(); + self.local_contact_464 = [0.; 4]; + self.adjustment_496 = [0.; 4]; + self.vector_512 = [0., 0., 1., 0.]; + self.restart_normal_528 = [0., 0., 1., 0.]; + } + + ///State-local82D2E5C0. Caller performs manager/feet reset and conditional + ///launch, then Biped.place between these two explicit entry stages. + pub fn begin_enter(&mut self, input: EnterInput) { + self.reset(); + self.begin_enter_after_reset(input); + } + ///Host places the shared landing reset between Air reset and initialization. + pub fn begin_enter_after_reset(&mut self, input: EnterInput) { + self.frame_80 = input.animation_frame; + self.flags_544_550[6] = input.flags_2484 & 0x4000 != 0; + self.frame_144 = IDENTITY; + self.frame_208 = input.animation_frame; + self.initial_up_480 = input.animation_frame[1]; + self.active = true; + } + pub fn finish_enter(&mut self, input: EnterInput) { + self.body_target_416 = input.body_position_15872; + self.height_432 = dot( + sub(input.body_position_15872, input.position_592), + input.up_544, + ); + self.body_offset_436 = dot( + sub(input.body_position_15936, input.body_position_15872), + input.up_544, + ); + } + ///Only Enter's !selector8494 branch and contact restart call this producer. + pub fn launch_packet( + &self, + input: &air_launch::Processed, + biped: &super::super::controller::State, + turn: &crate::point_graph::PointGraph<8>, + settings: air_launch::Settings, + ) -> Result { + //82D7BA78 overwrites104 unconditionally, so the initializer's + //unwritten104 cannot escape the producer. No retained gameplay value. + let mut packet = air_launch::Packet::initialized(0.); + air_launch::produce(&mut packet, biped, turn, settings, input, false)?; + Ok(packet) + } + ///82D2EF20 clears the shared selector, not the landing manager or result. + pub fn exit(&mut self, selector: &mut sampling::SelectorState) { + selector.pending_8492 = false; + selector.preinitialized_8494 = false; + self.active = false; + } + pub fn begin_update(&mut self) -> i32 { + self.frame_452 = self.frame_452.wrapping_add(1); + self.frame_452 + } + ///82D2F170..434, after frame sampling and first-valid orientation setup. + pub fn update_times(&mut self) { + let duration = self.result.duration_388; + self.duration_448 = select(duration - DT, duration, DT); + if self.result.valid_404 { + self.time_remaining_444 = self.result.time_remaining_384; + let blend = 1. - self.time_remaining_444 / self.duration_448; + let lower = select(-blend, 0., blend); + self.blend_440 = select(1. - lower, lower, 1.); + } else { + self.blend_440 = 0.; + self.time_remaining_444 = 10.; + self.duration_448 = 10.; + } + } + ///82D2F564..650. Receives the SAME Biped cadence used while walking. + pub fn update_cadence( + &mut self, + biped: &mut super::super::controller::State, + requested_phase: f32, + ) { + if self.result.valid_404 { + biped.correction_target_592 = self.result.contact_position_336; + let phase = &mut biped.cadence.phase; + if requested_phase >= 0. && self.time_remaining_444 > 0. { + phase.forward_target = true; + let delta = requested_phase - phase.phase; + phase.target = requested_phase; + phase.duration = Some(self.time_remaining_444); + phase.rate = if delta < 0. { + (delta + 1.) / self.time_remaining_444 + } else { + delta / self.time_remaining_444 + }; + } + phase.advance(); + let frame = biped.motion.frame_0; + let delta = sub(self.result.contact_position_336, frame[3]); + self.local_contact_464 = [ + dot(frame[0], delta), + dot(frame[1], delta), + dot(frame[2], delta), + 0., + ]; + } else { + self.local_contact_464 = [0., 0., 1., 0.]; + } + self.cadence = biped.cadence; + } + pub fn output(&self) -> OffBoardOutput { + OffBoardOutput { + scalar_32: self.time_remaining_444, + vector_64: self.result.velocity_288, + scalar_92: self.duration_448, + vector_96: self.local_contact_464, + word_144: self.result.word_408, + scalar_148: self.result.scalar_392, + scalar_152: self.frame_452 as f32 * DT, + scalar_156: self.result.apex_time_396, + vector_160: self.initial_up_480, + vector_176: self.frame_80[3], + vector_192: self.result.normal_304, + vector_208: self.result.contact_position_336, + vector_224: self.frame_144[2], + vector_240: self.result.apex_368, + flag_320: self.time_remaining_444 <= DT, + flag_328: self.flags_544_550[4], + flag_331: true, + } + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/result.rs b/crates/skate-core/src/player/offboard/biped_air/result.rs new file mode 100644 index 00000000..75704d1e --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/result.rs @@ -0,0 +1,57 @@ +//! TU3 82D2DAB8 result storage and 82D30300 OffBoard publication. +use super::Vector; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TrajectoryResult { + pub position_272: Vector, + pub velocity_288: Vector, + pub normal_304: Vector, + pub contact_velocity_320: Vector, + pub contact_position_336: Vector, + pub adjustment_352: Vector, + pub apex_368: Vector, + pub time_remaining_384: f32, + pub duration_388: f32, + pub scalar_392: f32, + pub apex_time_396: f32, + pub frame_400: i32, + pub valid_404: bool, + pub word_408: u32, +} +impl TrajectoryResult { + ///82D2DAB8 retains result+80/+96/+124. Do not zero the whole packet. + pub fn reset(&mut self) { + self.position_272 = [0.0; 4]; + self.velocity_288 = [0.0; 4]; + self.normal_304 = [0.0; 4]; + self.contact_velocity_320 = [0.0; 4]; + self.contact_position_336 = [0.0; 4]; + self.time_remaining_384 = 0.0; + self.duration_388 = 0.0; + self.scalar_392 = 0.0; + self.frame_400 = 0; + self.valid_404 = false; + self.word_408 = 0; + } +} +///Every offset is relative to PhysOut's OffBoard child, never Air. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OffBoardOutput { + pub scalar_32: f32, + pub vector_64: Vector, + pub scalar_92: f32, + pub vector_96: Vector, + pub word_144: u32, + pub scalar_148: f32, + pub scalar_152: f32, + pub scalar_156: f32, + pub vector_160: Vector, + pub vector_176: Vector, + pub vector_192: Vector, + pub vector_208: Vector, + pub vector_224: Vector, + pub vector_240: Vector, + pub flag_320: bool, + pub flag_328: bool, + pub flag_331: bool, +} diff --git a/crates/skate-core/src/player/offboard/biped_air/sampling.rs b/crates/skate-core/src/player/offboard/biped_air/sampling.rs new file mode 100644 index 00000000..755f923f --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/sampling.rs @@ -0,0 +1,75 @@ +//! Original offboard selector GetTrajectoryResultAtFrame82D6E3F8. +//! This selector is distinct from the on-board TrajectorySelector82D67848. +use super::{DT,TrajectoryResult,Vector}; +use crate::{air::trajectory::Trajectory,point_graph::PointGraph}; + +///Retained data produced by offboard selection82D6CC50 and adjustment82D6DE08. +///No Default: completing a scene query must supply these actual observations. +#[derive(Clone, Copy, Debug)] +pub struct Selection { + pub trajectory_8144: Trajectory, + pub normal_6144: Vector, + pub velocity_6160: Vector, + pub position_6176: Vector, + pub valid_6200: bool, + pub result_present_3888: bool, + pub landing_frame_8480: i32, + pub scalar_8392: f32, + pub word_8396: u32, + ///Selected candidate+100, or launch packet+100 when index8476 is -1. + pub candidate_scalar_100: f32, +} + +#[derive(Clone, Debug)] +pub struct SelectorState { + pub pending_8492: bool, + pub restart_allowed_8493: bool, + pub preinitialized_8494: bool, + pub fallback_8208: Trajectory, + pub selection: Selection, + pub adjustment_8336: Vector, + pub blend_8384: f32, + pub elapsed_8388: f32, + pub frame_8484: i32, +} +impl SelectorState { + ///The game constructs this from the actual launch/selection producer. + pub fn sample(&mut self, frame:i32, timestep:f32, blend:&PointGraph<8>, out:&mut TrajectoryResult) { + self.frame_8484=frame; + let time=frame as f32*DT; + let s=self.selection; + if self.pending_8492 { + out.position_272=self.fallback_8208.position_at(time); + out.velocity_288=self.fallback_8208.velocity_at(time); + out.valid_404=false; + out.contact_position_336=[0.;4]; + out.normal_304=[0.,1.,0.,0.]; + out.contact_velocity_320=[0.;4]; + } else { + self.elapsed_8388+=timestep; + self.blend_8384=blend.evaluate(self.elapsed_8388); + let first=s.trajectory_8144.position_at(time); + let second=self.fallback_8208.position_at(time); + let weight=self.blend_8384; + out.position_272=core::array::from_fn(|i|second[i].mul_add(1.-weight,first[i]*weight)); + //The velocity is the selected trajectory velocity, NOT the + //derivative of the position blend and NOT the launch velocity. + out.velocity_288=s.trajectory_8144.velocity_at(time); + out.valid_404=s.valid_6200; + out.contact_position_336=s.position_6176; + out.normal_304=s.normal_6144; + out.contact_velocity_320=s.velocity_6160; + } + out.time_remaining_384=if s.result_present_3888 { + s.landing_frame_8480.wrapping_sub(frame) as f32*DT + } else {0.}; + out.duration_388=s.scalar_8392; + out.frame_400=frame; + out.adjustment_352=self.adjustment_8336; + let (apex,time)=s.trajectory_8144.highest_position(); + out.apex_368=apex; + out.apex_time_396=time; + out.word_408=s.word_8396; + out.scalar_392=s.candidate_scalar_100; + } +} diff --git a/crates/skate-core/src/player/offboard/biped_air/selection.rs b/crates/skate-core/src/player/offboard/biped_air/selection.rs new file mode 100644 index 00000000..d6d379bf --- /dev/null +++ b/crates/skate-core/src/player/offboard/biped_air/selection.rs @@ -0,0 +1,110 @@ +//! Offboard selector commit82D6CC50, after scoring82D6D020. +//! Original TU3 disassembly; this is not the on-board selector's selection rule. +use super::{DT, Vector, sampling::{Selection, SelectorState}}; +use crate::air::trajectory::{Prediction, Trajectory}; + +///Candidate4032+128*i. The trajectory occupies0..63;100 is contact-position Y. +#[derive(Clone, Copy, Debug)] +pub struct Candidate { + pub trajectory: Trajectory, + pub normal_64: Vector, + pub contact_velocity_80: Vector, + pub contact_position_96: Vector, + pub start_frame_112: i32, + pub landing_frame_116: i32, + pub valid_120: bool, + pub special_121: bool, +} +impl Candidate { + ///82D6BD78 clears every candidate, independently of the launch packet. + pub fn reset() -> Self { + Self { + trajectory: cleared_trajectory(), normal_64:[0.;4], + contact_velocity_80:[0.;4], contact_position_96:[0.;4], + start_frame_112:0, landing_frame_116:0, valid_120:false, special_121:false, + } + } +} + +pub fn cleared_trajectory() -> Trajectory { + Trajectory { position:[0.;4], velocity:[0.;4], acceleration:[0.;4], duration:-1. } +} + +///82D6CC88..CCBC uses a strict comparison; equal scores keep the earlier index. +///The native caller always supplies at least one candidate (packet108 >=1). +pub fn selected_index(scores: &[f32]) -> Option { + if scores.is_empty() { return None; } + let mut best = f32::from_bits(0xccbe_bc20); //Original822F9420, -100000000. + let mut index = 0; + for (candidate, &score) in scores.iter().enumerate() { + if score > best { best=score; index=candidate; } + } + Some(index) +} + +fn shift(trajectory: &mut Trajectory, time:f32) { + let position=trajectory.position_at(time); + let velocity=trajectory.velocity_at(time); + trajectory.position=position; + trajectory.velocity=velocity; +} + +impl SelectorState { + ///82D6BD78's sample-visible reset. Packet3904 is NOT reset by that function; + ///when index8476 is -1, sampling still reads its retained scalar4004. + pub fn reset_sampling(retained_launch_scalar_100:f32) -> Self { + Self { + pending_8492:false, restart_allowed_8493:true, preinitialized_8494:false, + fallback_8208:cleared_trajectory(), + selection:Selection { + trajectory_8144:cleared_trajectory(), normal_6144:[0.;4], + velocity_6160:[0.;4], position_6176:[0.;4], valid_6200:false, + result_present_3888:false, landing_frame_8480:1000, + scalar_8392:0., word_8396:0, candidate_scalar_100:retained_launch_scalar_100, + }, + adjustment_8336:[0.;4], blend_8384:0., elapsed_8388:0., frame_8484:0, + } + } + + ///82D6CA58's fallback seed. Call once per actual launch, never per Update. + ///Candidate construction and query submission follow this stage. + pub fn seed_fallback(&mut self, position:Vector, velocity:Vector, gravity:Vector) { + self.fallback_8208=Trajectory { + position, velocity, acceleration:gravity, + duration:f32::from_bits(0x4000_0000), //Original82060C50=2 seconds. + }; + self.pending_8492=true; + } + + ///Commit the candidate chosen by82D6D020/CC50. Return native byte8497's + ///set event; a miss does not clear that retained flag or8392/8396. + pub fn commit(&mut self, candidate:Candidate, prediction:&mut Prediction, offset_8272:Vector) -> bool { + self.pending_8492=false; + let time=candidate.start_frame_112.wrapping_neg() as f32*DT; + if prediction.result.valid() { + prediction.result.contact_frame=prediction.result.contact_frame.wrapping_add(candidate.start_frame_112); + prediction.result.contact_time-=time; + } + //The query trajectory is shifted back but does not receive body offset. + shift(&mut prediction.request.trajectory,time); + let mut trajectory=candidate.trajectory; + shift(&mut trajectory,time); + trajectory.position=core::array::from_fn(|i|trajectory.position[i]+(-offset_8272[i])); + let mut duration=self.selection.scalar_8392; + let mut word=self.selection.word_8396; + let contact=prediction.result.valid(); + if contact { + duration=candidate.landing_frame_116 as f32*DT; + word=if candidate.special_121 {2} else {(prediction.result.surface>>7)&0x1f}; + } + self.selection=Selection { + trajectory_8144:trajectory, normal_6144:candidate.normal_64, + velocity_6160:candidate.contact_velocity_80, position_6176:candidate.contact_position_96, + valid_6200:candidate.valid_120, result_present_3888:true, + landing_frame_8480:candidate.landing_frame_116, scalar_8392:duration, word_8396:word, + candidate_scalar_100:candidate.contact_position_96[1], + }; + self.preinitialized_8494=true; + contact + } +} diff --git a/crates/skate-core/src/player/offboard/board_possession.rs b/crates/skate-core/src/player/offboard/board_possession.rs index 14b245ad..c5708137 100644 --- a/crates/skate-core/src/player/offboard/board_possession.rs +++ b/crates/skate-core/src/player/offboard/board_possession.rs @@ -4,6 +4,9 @@ pub mod manager; pub mod math; pub mod lifecycle; pub mod motion; +pub mod angular; +#[cfg(test)] +mod tests; pub type Vector = [f32;4]; pub type Frame = [Vector;4]; pub const IDENTITY: Frame = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.;4]]; diff --git a/crates/skate-core/src/player/offboard/board_possession/angular.rs b/crates/skate-core/src/player/offboard/board_possession/angular.rs new file mode 100644 index 00000000..33960303 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/angular.rs @@ -0,0 +1,17 @@ +//!82C07328: requested angular displacement becomes deck angular acceleration. +//! Keep both inverse-inertia transforms: cancellation would change rounding and +//! singular-matrix behavior. Ordinary PC reciprocal seeds are not Xenon parity. +use super::{Vector,math::*}; +pub fn acceleration_delta(request:Vector,omega:Vector,inverse_inertia:[Vector;3])->Vector { + let [a,b,c]=inverse_inertia; + let cofactor=[cross(b,c),cross(c,a),cross(a,b)]; + let determinant=dot(a,cofactor[0]); + let inv_det=reciprocal(determinant,2); + let inverse:[Vector;3]=std::array::from_fn(|i|[ + cofactor[0][i]*inv_det,cofactor[1][i]*inv_det,cofactor[2][i]*inv_det,0.]); + let dt=f32::from_bits(0x3c888889); + let axis=unit(request,[0.;4]); + let residual=sub(request,scale(axis,dot(axis,scale(omega,dt)))); + let local=transform_direction([inverse[0],inverse[1],inverse[2],[0.;4]],scale(residual,reciprocal(dt,2))); + transform_direction([a,b,c,[0.;4]],scale(local,reciprocal(dt,2))) +} diff --git a/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs index d3aed35f..7f7a5aeb 100644 --- a/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs +++ b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs @@ -35,6 +35,14 @@ pub trait Effects { fn torque(&mut self,value:Vector); } impl State { + ///75EA0 retains the optional throw's word444 write made inside LetGo. + pub fn stop(&mut self,f:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + if f.system_on_452 { + f.word_444=0; + if f.state_448!=0 {self.let_go(f,o,s,e);f.state_448=0;} + f.system_on_452=false; + } + } ///75370 writes held state BEFORE selecting/arming the hand and drive frames. pub fn hold(&mut self,fields:&mut SkateboardControllerFields,o:&Observation,e:&mut impl Effects){ e.enable_animation_soft();e.standard_board();e.collision_volumes(true); diff --git a/crates/skate-core/src/player/offboard/board_possession/tests.rs b/crates/skate-core/src/player/offboard/board_possession/tests.rs new file mode 100644 index 00000000..0552d0f3 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/tests.rs @@ -0,0 +1,85 @@ +use super::{*,lifecycle::*}; +use crate::point_graph::PointGraph; +#[derive(Default)]struct Recorder{calls:Vec<&'static str>,velocities:Vec} +impl Effects for Recorder{ + fn enable_animation_soft(&mut self){self.calls.push("soft");} + fn enable_animation_angular_only(&mut self){self.calls.push("angular");} + fn disable_animation(&mut self){self.calls.push("off");} + fn disable_linear_drive(&mut self){self.calls.push("linear_off");} + fn standard_board(&mut self){self.calls.push("standard");} + fn released_board(&mut self){self.calls.push("released");} + fn collision_volumes(&mut self,e:bool){self.calls.push(if e{"collision_on"}else{"collision_off"});} + fn clear_alignment(&mut self){self.calls.push("unalign");} + fn alignment(&mut self,_:Alignment){self.calls.push("align");} + fn velocity(&mut self,v:Vector){self.calls.push("velocity");self.velocities.push(v);} + fn position(&mut self,_:Vector){self.calls.push("position");} + fn hook_frame(&mut self,_:Frame){self.calls.push("hook");} + fn target_position_velocity(&mut self,_:Vector){self.calls.push("target_velocity");} + fn torque(&mut self,_:Vector){self.calls.push("torque");} +} +fn settings()->Settings{ + let constant=|v|PointGraph{x:[0.,1.,2.,3.,4.,5.,6.,7.],y:[v;8]}; + Settings{hide_distance:30.,hide_offset:1000.,return_distance:30.,mounted_return_distance:5., + mounting_time:0.1,retrieval_time:constant(1.),retrieval_weight:constant(0.5), + throw_pitch:18.,throw_velocity:constant(1.),throw_target_pitch:20., + throw_pitch_scalar:0.1,throw_roll_scalar:0.1,throw_yaw_scalar:0.2} +} +fn observation()->Observation{Observation{processed:Processed{board_frame_64:IDENTITY,player_frame_192:IDENTITY, + position_592:[0.;4],velocity_912:[0.;4],direction_400:[0.,0.,1.,0.],hide_direction_464:[0.,0.,1.,0.], + flags_2476:0,flags_2480:0,flags_2488:0},board_collision_flags_872:0,board_state_840:0, + hand_contacts:[false;2],physical_hand_positions:[[0.;4];2],animation_board_frame_12624:IDENTITY, + animation_hand_frames:[IDENTITY;2],attachment_frame_0:IDENTITY}} +fn fields(state:u32)->SkateboardControllerFields{SkateboardControllerFields{state_448:state,word_444:0,system_on_452:true}} +#[test]fn drop_runs_same_tick_first_throw_and_exactly_eight_batches(){ + let(mut s,mut f,mut o,mut e)=(State::default(),fields(1),observation(),Recorder::default()); + o.processed.flags_2476=0x3000; + s.update(&mut f,&o,&settings(),&mut e); + assert_eq!((f.state_448,f.word_444),(2,7)); + assert_eq!(&e.calls[..5],&["off","released","collision_on","unalign","velocity"]); + for _ in 0..8{s.update(&mut f,&o,&settings(),&mut e);} + assert_eq!(e.calls.iter().filter(|x|**x=="torque").count(),24); + assert_eq!(f.word_444,0); +} +#[test]fn recall_wins_over_distance_hide_and_mount_clamps_duration_down(){ + let(mut s,mut f,mut o,mut e)=(State::default(),fields(2),observation(),Recorder::default()); + o.processed.board_frame_64[3]=[100.,0.,0.,7.];o.processed.flags_2480=0x80000; + s.update_state(&mut f,&o,&settings(),&mut e); + assert_eq!(f.state_448,4);assert_eq!(s.retrieval.duration_196,0.1); + assert!((s.retrieval.initial_0[3][0]-5.).abs()<1e-5); + assert_ne!(s.retrieval.initial_0[3][3],0.); + assert_eq!(e.calls,vec!["collision_off","unalign","angular","position"]); +} +#[test]fn returning_contact_precedes_completion_but_mount_ignores_contact(){ + let(mut s,mut f,mut o,mut e)=(State::default(),fields(4),observation(),Recorder::default()); + s.retrieval.progress_200=1.;o.hand_contacts[1]=true; + s.update_state(&mut f,&o,&settings(),&mut e);assert_eq!(f.state_448,2); + f.state_448=4;o.processed.flags_2480=0x80000;e.calls.clear(); + s.update_state(&mut f,&o,&settings(),&mut e);assert_eq!(f.state_448,1); + assert_eq!(e.calls,vec!["velocity","soft","standard","collision_on"]); +} +#[test]fn unordered_progress_completes_and_only_selected_hand_is_armed(){ + let(mut s,mut f,o,mut e)=(State::default(),fields(4),observation(),Recorder::default()); + s.retrieval.progress_200=f32::NAN;s.update_state(&mut f,&o,&settings(),&mut e); + assert_eq!(s.selected_hand_424,1);assert_eq!(s.hands[0].dynamics,[[0,0,0,2];2]); + assert_eq!(s.hands[1].dynamics,[[0x4395ffff,0,0x468c9fff,2];2]); +} +#[test]fn stop_preserves_throw_word_from_release_and_off_controller_is_inert(){ + let(mut s,mut f,mut o,mut e)=(State::default(),fields(1),observation(),Recorder::default()); + o.processed.flags_2476=0x1000;s.stop(&mut f,&o,&settings(),&mut e); + assert_eq!(f.word_444,8);assert_eq!(f.state_448,0);assert!(!f.system_on_452); + e.calls.clear();s.update(&mut f,&o,&settings(),&mut e);s.stop(&mut f,&o,&settings(),&mut e); + assert!(e.calls.is_empty());assert_eq!(f.word_444,8); +} +#[test]fn angular_request_rejects_orthogonal_omega_and_retains_singular_behavior(){ + let tensor=[[2.,0.,0.,0.],[0.,3.,0.,0.],[0.,0.,5.,0.]]; + let a=angular::acceleration_delta([0.1,0.,0.,0.],[2.,99.,0.,0.],tensor); + assert!((a[0]-240.).abs()<0.01);assert_eq!(a[1],0.); + let singular=angular::acceleration_delta([0.1,0.,0.,0.],[0.;4],[[0.;4];3]); + assert!(singular[..3].iter().any(|v|v.is_nan())); +} +#[test]fn fill_uses_hidden_initial_position_and_zero_axis_quadrant(){ + let mut s=State::default();let o=observation();s.retrieval.initial_0[3]=[1.,0.,0.,0.]; + let result=fill(&fields(3),&s,&o.processed,IDENTITY); + assert_eq!(result.angle_36.to_bits(),0x3fc90fdb);assert!(result.free_312&&result.hiding_321); + assert_eq!(math::quadrant_angle(-0.,0.).to_bits(),0xbfc90fdb); +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit.rs b/crates/skate-core/src/player/offboard/contact_toolkit.rs index a1cf45e3..a6a7e698 100644 --- a/crates/skate-core/src/player/offboard/contact_toolkit.rs +++ b/crates/skate-core/src/player/offboard/contact_toolkit.rs @@ -1,271 +1,210 @@ -//! BipedToolkit refresh82D81610. Query observations are owned by the scene; -//! this owner retains native contact history and executes the classifier chain. -use super::{ - contact_intersections::plane_segment, - contact_output::History, - contact_packet::{Packet, SupportHit}, - contact_queries::{Input, Layout, V}, - contact_records::{Direction, Records, Source}, - contact_segments::Segments, - ground_query::Edge, -}; -use crate::{math::Vector3, physics::native_arithmetic::dot3}; -#[derive(Clone, Copy, Debug)] -pub struct Hit { - pub position: V, - pub normal: V, +//! OffboardGroundAnalyzer query ownership (TU3 82D81068/82D811C8). +//! +//! Submitted queries become visible only through the ordered analyzer refresh. +mod analyzer_math; +mod candidate; +mod classification; +mod collection; +mod generation; +mod infill; +mod prefix; +mod probes; +mod profile; +mod publication; +mod samples; +mod sweep; +#[cfg(test)] +mod tests; + +use crate::air::trajectory::QueryResult; +pub use prefix::ContactPrefix; +pub use probes::{Batch, Input, LineProbe, ProbeLayout}; +pub use samples::{ContactSample, Samples}; +pub use sweep::query_sweep; + +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +pub const UP: Vector = [0., 1., 0., 0.]; +pub const ZERO: Vector = [0.; 4]; +pub const IDENTITY: Frame = [[1., 0., 0., 0.], UP, [0., 0., 1., 0.], ZERO]; + +/// Unit face normal shared by the toolkit's scene adapter and query kernels. +pub fn triangle_normal([a, b, c]: [Vector; 3]) -> Vector { + let normal = cross(sub(b, a), sub(c, a)); + let square = dot(normal, normal); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.), inverse); + } + scale(normal, inverse) } -pub struct Observations { - pub input: Input, - pub support: [Option; 3], - /// Provider IDs from Layout, including the reverse horizontal queries. - pub obstacles: [Option; 44], - /// Actual82C1EAD8 results in native provider order and capped at40. - pub edges: Vec, + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineHit { + pub position: Vector, + pub normal: Vector, + pub fraction: f32, + pub surface: u16, + pub mesh_frame: Frame, + pub geometry: u32, } -#[derive(Default)] -pub struct Toolkit { - pub packet: Packet, - pub history: History, - pub contact_age: i32, - pub pending: Option, + +/// Actual batch results, indexed in submission order, not sorted by distance. +#[derive(Clone, Debug)] +pub struct QueryResults { + pub trajectories: [QueryResult; 3], + pub lines: Vec>, + /// Authored static query edges in canonical traversal order, at most forty. + pub edges: Vec<[Vector; 2]>, } -fn vector(v: Vector3) -> V { - [v.x, v.y, v.z, 0.] + +/// The scene owns geometry; the toolkit owns query lifetime and retained output. +pub trait Scene { + type Error; + fn execute(&self, batch: &Batch) -> Result; } -fn xyz(v: V) -> Vector3 { - Vector3::new(v[0], v[1], v[2]) + +#[derive(Clone, Debug)] +pub struct Collected { + pub batch: Batch, + pub results: QueryResults, + pub prefix: ContactPrefix, + pub samples: Samples, } -fn sub(a: V, b: V) -> V { + +/// Canonical lifetime of submission316, readiness320, and input0..96. +/// No executor or scene reference is retained across frames. +#[derive(Clone, Debug)] +pub struct Owner { + layout: ProbeLayout, + pending: Option<(Batch, QueryResults)>, + readiness: u32, + prefix: ContactPrefix, + candidate: candidate::Candidate, + history: classification::History, +} +impl Default for Owner { + fn default() -> Self { + Self { + layout: ProbeLayout::stock(), + pending: None, + readiness: 0, + prefix: ContactPrefix::reset(), + candidate: candidate::Candidate::default(), + history: classification::History::default(), + } + } +} +impl Owner { + /// Update1_ProcessInput82DB405C..4094 ages then completes the preceding + /// batch before physical input is processed, regardless of active state. + pub fn begin_input(&mut self) { + self.readiness = self.readiness.saturating_sub(1); + self.refresh(); + } + + /// Ground Reset82D30C20..40 / physical reset82DB93B0..CC. + /// Complete outstanding work first; only readiness and classification + /// history are cleared. The original does not erase the retained candidate. + pub fn reset_history(&mut self) { + self.refresh(); + self.readiness = 0; + self.history = classification::History::default(); + } + + pub fn prefix(&self) -> ContactPrefix { + self.prefix + } + + /// GroundSync82D32164 supplies the seven vectors after Skeleton Update. + /// Host execution is synchronous, but visibility remains next-consume only. + pub fn submit( + &mut self, + input: Input, + matching_group: i32, + scene: &S, + ) -> Result<(), S::Error> { + let batch = self.layout.prepare(input, matching_group); + let results = scene.execute(&batch)?; + self.readiness = 0; + self.pending = Some((batch, results)); + Ok(()) + } + + /// Consume the prior submission through every analyzer stage. Candidate and + /// classification history persist; query observations are frame-local. + pub fn refresh(&mut self) -> Option { + //82D81610 resets the publication even when there is no pending batch. + self.prefix = ContactPrefix::reset(); + self.readiness = 30; + let (batch, results) = self.pending.take()?; + let mut prefix = ContactPrefix::reset(); + prefix.consume_support(batch.input, &results.trajectories, self.candidate.flags); + let mut samples = collection::collect(&batch, &self.layout, &results); + infill::insert_obstacles(batch.input, &mut samples); + infill::correct_normals(batch.input, &mut samples); + let (obstruction, active_count) = profile::simplify(batch.input, &mut samples.ground); + prefix.distance_172 = obstruction; + let profile = profile::build(batch.input, &samples.ground[..active_count]); + let mut candidates = generation::generate( + batch.input, + &profile, + &prefix, + obstruction, + self.candidate.flags, + ); + publication::publish( + batch.input, + &profile, + &samples, + &mut candidates, + obstruction, + &mut self.candidate, + &mut self.history, + &mut prefix, + ); + self.prefix = prefix; + Some(Collected { + batch, + results, + prefix, + samples, + }) + } + + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } + pub fn readiness(&self) -> u32 { + self.readiness + } +} + +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) } -impl Toolkit { - /// Player Update82DB4048 completes the preceding Sync's submission before - /// any physical-state PreUpdate. The source decrements320, then refreshes - /// unconditionally (refresh itself publishes30 even with no pending query). - pub fn begin_player_update(&mut self, layout: &Layout) { - self.contact_age = self.contact_age.saturating_sub(1).max(0); - self.refresh(layout); - } - /// Reset82D30BD0 / Exit82D30CC0 must finish outstanding providers before - /// clearing the completion latch and classifier history. - pub fn reset_contacts(&mut self, layout: &Layout) { - self.refresh(layout); - self.contact_age = 0; - self.history = History::default(); - } - pub fn submit(&mut self, observations: Observations) { - self.pending = Some(observations); - self.contact_age = 0; - } - pub fn refresh(&mut self, layout: &Layout) { - self.packet = Packet::default(); - self.contact_age = 30; - let Some(observations) = self.pending.take() else { - return; - }; - let input = observations.input; - let previous_flags = self.history.retained_candidate.map_or(0, |c| c.flags); - let mut records = Records::default(); - self.packet - .consume_support(input, observations.support, previous_flags, &mut records); - gather(layout, &observations, &mut records); - super::contact_sort::sort(&mut records.surface); - super::contact_sort::sort(&mut records.obstacle); - super::contact_promotion::promote(input, &mut records); - super::contact_intersections::constrain(input, &mut records); - let reduction = super::contact_simplify::simplify(input, &mut records.surface); - self.packet.distance_172 = reduction.forward_limit; - let mut segments = Segments::default(); - segments.rebuild(input, &records.surface); - let mut candidates = super::contact_classify::candidates( - input, - &self.packet, - &segments, - reduction.forward_limit, - previous_flags, - ); - super::contact_output::publish( - input, - &mut self.packet, - &records, - &segments, - &reduction, - &mut candidates, - &mut self.history, - ); - } +pub(super) fn scale(a: Vector, s: f32) -> Vector { + a.map(|v| v * s) } - -/// Bounds at19056/19072, used by the scene to obtain real query edges. -pub fn edge_bounds(input: Input) -> (V, V) { - let center: V = std::array::from_fn(|i| { - input.surface_up[i].mul_add( - f32::from_bits(0x3e99_9999), - input.surface_forward[i].mul_add(f32::from_bits(0x3f66_6666), input.position[i]), - ) - }); - let extent: V = std::array::from_fn(|i| { - input.surface_forward[i].abs().mul_add( - f32::from_bits(0x3f66_6666), - input.surface_up[i].abs().mul_add( - f32::from_bits(0x3f8c_cccd), - input.surface_right[i].abs() * f32::from_bits(0x3d19_999a), - ), - ) - }); - ( - std::array::from_fn(|i| center[i] - extent[i]), - std::array::from_fn(|i| center[i] + extent[i]), - ) +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] } -fn edge_angle(delta: V, forward: V) -> f32 { - let angle = crate::physics::board_ground::angle_between(xyz(delta), xyz(forward)); - let turns = angle * f32::from_bits(0x3e22_f983); - let fraction = turns - turns.floor(); - let wrapped = (fraction - if fraction > 0.5 { 1. } else { 0. }) * f32::from_bits(0x40c9_0fdb); - let sign = if wrapped > 0. { 1. } else { -1. }; - let absolute = wrapped * sign; - sign * if absolute > f32::from_bits(0x3fc9_0fdb) { - absolute - f32::from_bits(0x4049_0fdb) - } else { - absolute - } -} -fn gather(layout: &Layout, observations: &Observations, records: &mut Records) { - let input = observations.input; - let (_, world) = layout.world_probes(input); - let mut overrides: Vec> = vec![None; layout.vertical.len()]; - for edge in observations.edges.iter().take(40) { - let start = vector(edge.start); - let end = vector(edge.end); - let delta = sub(end, start); - if edge_angle(delta, input.surface_forward).abs() > 30. * f32::from_bits(0x3c8e_fa35) { - if let Some(position) = plane_segment(input.position, input.surface_right, start, end) { - let mut normal = input.surface_up; - records.insert( - input, - position, - &mut normal, - Source::Intersection, - Direction::None, - -1., - ); - } - continue; - } - let a = dot3(sub(start, input.position), input.surface_forward); - let b = dot3(sub(end, input.position), input.surface_forward); - let crossing: V = std::array::from_fn(|i| delta[i].mul_add(a / (a - b), start[i])); - if dot3(input.surface_right, sub(crossing, input.position)).abs() > 0.5 { - continue; - } - let (low, high) = if a - b >= 0. { (b, a) } else { (a, b) }; - for (i, probe) in layout.vertical.iter().enumerate() { - let forward = probe.start[2]; - if !(forward >= low && high >= forward) { - continue; - } - let t = (forward - a) / (b - a); - let edge_point: V = std::array::from_fn(|k| delta[k].mul_add(t, start[k])); - let base = world[i].start; - let h = dot3(input.surface_up, sub(edge_point, base)); - let point: V = std::array::from_fn(|k| input.surface_up[k].mul_add(h, base[k])); - if dot3(input.surface_right, sub(point, edge_point)).abs() - >= f32::from_bits(0x3d4c_cccd) - { - continue; - } - if overrides[i].is_none_or(|previous| point[1] > previous[1]) { - overrides[i] = Some(point); - } - } - } - for (i, probe) in layout.vertical.iter().enumerate() { - let hit = observations.obstacles[probe.id]; - let chosen = match (hit, overrides[i]) { - (Some(hit), Some(position)) if position[1] > hit.position[1] => Some(Hit { - position, - normal: input.surface_up, - }), - (Some(hit), _) => Some(hit), - (None, Some(position)) => Some(Hit { - position, - normal: input.surface_up, - }), - (None, None) => None, - }; - if let Some(hit) = chosen { - let mut normal = hit.normal; - records.insert( - input, - hit.position, - &mut normal, - Source::Support, - Direction::None, - -1., - ); - } - } - for probe in &layout.horizontal { - for id in std::iter::once(probe.id).chain(probe.reverse_id) { - if let Some(hit) = observations.obstacles[id] { - let mut normal = hit.normal; - records.insert( - input, - hit.position, - &mut normal, - Source::Probe, - Direction::None, - -1., - ); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn reduced_forward_limit_reaches_the_retained_ground_packet() { - let input = Input { - position: [0.; 4], - surface_right: [1., 0., 0., 0.], - surface_up: [0., 1., 0., 0.], - surface_forward: [0., 0., 1., 0.], - animation_right: [1., 0., 0., 0.], - animation_up: [0., 1., 0., 0.], - velocity: [0., 0., 2., 0.], - }; - let layout = Layout::default(); - let mut obstacles = [None; 44]; - for i in 0..3 { - obstacles[layout.vertical[i].id] = Some(Hit { - position: [ - 0., - if i == 0 { 0. } else { 1. }, - layout.vertical[i].start[2], - 0., - ], - normal: input.surface_up, - }); - } - let support = SupportHit { - position: [0.; 4], - normal: input.surface_up, - frame: Packet::default().support_frame, - support_id: 1, - }; - let mut toolkit = Toolkit::default(); - toolkit.submit(Observations { - input, - support: [Some(support), None, None], - obstacles, - edges: Vec::new(), - }); - toolkit.refresh(&layout); - assert_eq!(toolkit.packet.distance_172, layout.vertical[1].start[2]); - assert!(toolkit.packet.position.iter().all(|x| x.is_finite())); +pub(super) fn length(v: Vector) -> f32 { + let square = dot(v, v); + if square == 0. { + return 0.; } + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.), inverse); + } + square * inverse } diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/analyzer_math.rs b/crates/skate-core/src/player/offboard/contact_toolkit/analyzer_math.rs new file mode 100644 index 00000000..fbe320d4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/analyzer_math.rs @@ -0,0 +1,86 @@ +//! Geometric helpers82E0A060/82E0A258/82D85C48 and tangent8252D980. +use super::{Vector, ZERO, cross, dot, length, scale, sub}; +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} +pub(super) fn reciprocal(x: f32) -> f32 { + let mut r = crate::physics::native_arithmetic::reciprocal_estimate(x); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(x, 1.), r); + } + r +} +pub(super) fn normalize(v: Vector, fallback: Vector) -> Vector { + let l = length(v); + if l > f32::from_bits(0x358637bd) { + scale(v, reciprocal(l)) + } else { + fallback + } +} +pub(super) fn plane_segment(p: Vector, n: Vector, a: Vector, b: Vector) -> Option { + let da = dot(n, sub(a, p)); + let db = dot(n, sub(b, p)); + if da * db >= 0. { + return None; + } + let aa = da.abs(); + let bb = db.abs(); + let sum = aa + bb; + Some(madd(a, bb / sum, scale(b, aa / sum))) +} +pub(super) fn clamp_normal(a: Vector, b: Vector, n: Vector) -> Vector { + let a = normalize(a, ZERO); + let b = normalize(b, ZERO); + let axis = normalize(cross(a, b), ZERO); + let projected = normalize(sub(n, scale(axis, dot(axis, n))), ZERO); + if ((dot(axis, axis) * dot(projected, projected)) * dot(a, a)) * dot(b, b) < 0.1 { + return n; + } + if dot(cross(a, projected), axis) > 0. && dot(cross(projected, b), axis) > 0. { + projected + } else if dot(projected, a) > dot(projected, b) { + a + } else { + b + } +} +pub(super) fn intersection(a: [f32; 2], b: [f32; 2], c: [f32; 2], d: [f32; 2]) -> f32 { + let u = [b[0] - a[0], b[1] - a[1]]; + let v = [d[0] - c[0], d[1] - c[1]]; + let denominator = v[1] * u[0] - v[0] * u[1]; + if denominator.abs() < 0.00001 { + return 1e10; + } + (1. / denominator) * (v[0] * (a[1] - c[1]) - v[1] * (a[0] - c[0])) +} +pub(super) fn tangent(x: f32) -> f32 { + if x == 0. { + return 0.; + } + let c = |bits| f32::from_bits(bits); + let turns = (x * c(0x3f22f983)).round_ties_even(); + let r = (-turns).mul_add(c(0x2e85a309), (-turns).mul_add(c(0x3fc90fdb), x)); + let q = r * r; + // IDA's VMX operand display is A,B,C: vmaddfp computes A*C+B. + // 8252DA20..38: denominator degree four, numerator correction degree two. + let den = q.mul_add( + q.mul_add( + q.mul_add(q.mul_add(c(0x3505bba8), c(0xb9a37b25)), c(0x3cd23cf5)), + c(0xbeeef582), + ), + 1., + ); + let poly = q.mul_add(q.mul_add(c(0xb795d5b9), c(0x3b607415)), c(0xbe0895af)); + let num = r.mul_add(q * poly, r); + let (num, den) = if r.abs() <= c(0x39800000) { + (r, 1.) + } else { + (num, den) + }; + if (turns.abs() as i32) & 1 == 0 { + num * reciprocal(den) + } else { + den * reciprocal(-num) + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/candidate.rs b/crates/skate-core/src/player/offboard/contact_toolkit/candidate.rs new file mode 100644 index 00000000..e34960ad --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/candidate.rs @@ -0,0 +1,193 @@ +//! Candidate record82D82380 and slope-projected approaches82D84D38/82D85070. +use super::analyzer_math::{intersection, madd, tangent}; +use super::profile::Profile; +use super::{Input, UP, Vector, ZERO, dot, length, sub}; +#[derive(Clone, Copy, Debug)] +pub(super) struct Candidate { + pub position: Vector, + pub normal: Vector, + pub direction: Vector, + pub flags: u32, + pub low: f32, + pub high: f32, + pub order: f32, + pub segment: i32, + pub kind: u32, +} +impl Default for Candidate { + fn default() -> Self { + Self { + position: ZERO, + normal: UP, + direction: ZERO, + flags: 0, + low: 0., + high: 0., + order: 0., + segment: 0, + kind: 0, + } + } +} +pub(super) struct Builder<'a> { + pub input: Input, + pub profile: &'a Profile, + pub obstruction: f32, + pub candidates: Vec, +} +impl Builder<'_> { + pub fn add( + &mut self, + position: Vector, + normal: Vector, + direction: Vector, + segment: i32, + flags: u32, + kind: u32, + ) { + let order = dot(self.input.forward, sub(position, self.input.position)); + let mut c = Candidate { + position, + normal, + direction, + segment, + flags, + kind, + order, + low: order, + high: order, + }; + if segment < 0 { + c.low = 0.; + } else { + let s = self.profile.segments[segment as usize]; + if s.kind != 0 { + let point = if s.kind == 2 { s.start } else { s.end }; + let distance = dot(self.input.forward, sub(point, self.input.position)); + if distance >= order { + c.high = distance; + } else { + c.low = distance; + } + } + } + self.candidates.push(c); + } + pub fn point(&mut self, position: Vector, normal: Vector, segment: i32, flags: u32, kind: u32) { + let delta = sub(position, self.input.position); + self.add( + position, + normal, + if length(delta) >= 0.02 { + delta + } else { + self.input.forward + }, + segment, + flags, + kind, + ); + } + fn local(&self, p: Vector) -> [f32; 2] { + let d = sub(p, self.input.position); + [dot(self.input.forward, d), dot(self.input.up, d)] + } + pub fn approach_up(&mut self, index: usize, flags: u32, slope: f32) { + let p = self.local(self.profile.segments[index].end); + let goal = [-1., (-1. - p[0]).mul_add(slope, p[1])]; + for i in (0..index).rev() { + let s = self.profile.segments[i]; + let t = intersection(self.local(s.start), self.local(s.end), p, goal); + if t < 0. { + if i == 0 { + self.point(self.input.position, self.input.up, index as i32, flags, 1); + return; + } + } else if t <= 1. { + let (position, normal) = if s.kind == 0 { + (madd(s.direction, s.length * t, s.start), s.normal) + } else { + (s.start, self.next_normal(i)) + }; + if self.local(position)[0] <= 0.75 { + self.point(position, normal, index as i32, flags, 1); + } + return; + } + } + } + pub fn approach_down(&mut self, index: i32, flags: u32, slope: f32, kind: u32) { + let point = if index >= 0 { + self.profile.segments[index as usize].start + } else { + self.input.position + }; + let p = self.local(point); + let goal = [2.8, (2.8 - p[0]).mul_add(slope, p[1])]; + for i in (index + 1) as usize..self.profile.last_ground { + let s = self.profile.segments[i]; + let t = intersection(self.local(s.start), self.local(s.end), p, goal); + if t >= 0. && t <= 1. { + if s.kind == 0 { + self.point( + madd(s.direction, s.length * t, s.start), + s.normal, + index, + flags, + kind, + ); + } else if self.local(s.end)[0] <= self.obstruction - 0.35 { + self.point(s.end, self.next_normal(i), index, flags, kind); + } + return; + } + } + } + pub fn advance(&mut self, mut distance: f32) { + for i in 0..self.profile.last_ground { + let s = self.profile.segments[i]; + if s.kind == 2 { + distance += s.length / down_slope(self.input, s.length); + continue; + } + distance -= s.length; + if distance > 0. { + continue; + } + if s.kind == 1 { + self.point(s.end, self.next_normal(i), i as i32, 0, 9); + } else { + self.point(madd(s.direction, distance, s.end), s.normal, i as i32, 0, 9); + } + return; + } + } + pub fn next_normal(&self, i: usize) -> Vector { + if i == self.profile.last_ground - 1 { + self.input.up + } else { + self.profile.segments[i + 1].normal + } + } +} +pub(super) fn up_slope(input: Input, height: f32) -> f32 { + let (low, high) = if height > 0.4 { (35., 50.) } else { (15., 30.) }; + slope(input, low, high) +} +pub(super) fn down_slope(input: Input, height: f32) -> f32 { + let (low, high) = if height > 0.4 { (30., 50.) } else { (15., 30.) }; + slope(input, low, high) +} +fn slope(input: Input, low: f32, high: f32) -> f32 { + let speed = length(input.velocity).max(2.).min(6.); + tangent((((speed - 2.) * (low - high)) * 0.25 + high) * 0.017453292) +} +pub(super) fn between_slope(input: Input, a: Vector, b: Vector) -> f32 { + let d = sub(b, a); + let x = dot(input.forward, d); + if x < 0.001 { + 1000. + } else { + super::analyzer_math::reciprocal(x) * dot(input.up, d) + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/classification.rs b/crates/skate-core/src/player/offboard/contact_toolkit/classification.rs new file mode 100644 index 00000000..bc3cf8f8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/classification.rs @@ -0,0 +1,58 @@ +//! TU3 82D848D0: slope classification and retained three-sample acceptance. +use super::analyzer_math::tangent; +use super::{ContactPrefix, Vector, length}; + +/// Toolkit fields +30452..30468, retained across query refreshes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct History { + accepted: u32, + samples: [u32; 3], + cursor: usize, +} + +impl History { + pub fn classify(&mut self, prefix: &mut ContactPrefix, direction: Vector) { + let horizontal = length([direction[0], 0., direction[2], 0.]); + // 82D84994 uses scalar division, not the vector reciprocal helper. + let inverse = 1. + / if 0.001 - horizontal >= 0. { + 0.001 + } else { + horizontal + }; + let slope = inverse * direction[1]; + prefix.scalar_160 = slope; + // Stock globals 822F8FCC/822F8FD0; arguments are radians. + let low = tangent(f32::from_bits(0x3dd67750)); + let high = tangent(f32::from_bits(0x3f0efa35)); + let magnitude = slope.abs(); + let proposed = if magnitude <= high { + if magnitude <= low { + 0 + } else if slope > 0. { + 1 + } else { + 3 + } + } else if slope > 0. { + 2 + } else { + 4 + }; + let proposed = if proposed != 0 && prefix.flags_176 & 0x40 == 0 { + proposed + 4 + } else { + proposed + }; + prefix.kind_164 = proposed; + if proposed == 0 { + *self = Self::default(); + } else if self.samples.iter().all(|&sample| sample == proposed) { + self.accepted = proposed; + } else { + self.cursor = (self.cursor + 1) % 3; + self.samples[self.cursor] = proposed; + prefix.kind_164 = self.accepted; + } + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/collection.rs b/crates/skate-core/src/player/offboard/contact_toolkit/collection.rs new file mode 100644 index 00000000..a1cce654 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/collection.rs @@ -0,0 +1,103 @@ +//! TU3 82D81610: authored-edge/secondary-line merge, then primary lines. +use super::analyzer_math::{madd, plane_segment}; +use super::{Batch, ProbeLayout, QueryResults, Samples, Vector, dot, scale, sub}; + +pub(super) fn collect(batch: &Batch, layout: &ProbeLayout, results: &QueryResults) -> Samples { + let input = batch.input; + let mut samples = Samples::default(); + let main = results.trajectories[0]; + if main.valid() { + samples.insert(input, main.contact_position, main.landing_normal, 1, -1., 0); + } + let mut edge_points: Vec> = vec![None; batch.secondary.len()]; + for &[start, end] in &results.edges { + let delta = sub(end, start); + if folded_angle(delta, input.forward).abs() > 30. * 0.017453292 { + if let Some(point) = plane_segment(input.position, input.right, start, end) { + samples.insert(input, point, input.up, 2, -1., 0); + } + continue; + } + let a = dot(sub(start, input.position), input.forward); + let b = dot(sub(end, input.position), input.forward); + let at_origin = madd(delta, a / (a - b), start); + if dot(input.right, sub(at_origin, input.position)).abs() > 0.5 { + continue; + } + for ((local, world), point) in layout + .secondary + .iter() + .zip(&batch.secondary) + .zip(&mut edge_points) + { + let z = local.line.start[2]; + if z < a.min(b) || z > a.max(b) { + continue; + } + let on_edge = madd(delta, (z - a) / (b - a), start); + let projected = madd( + input.up, + dot(input.up, sub(on_edge, world.line.start)), + world.line.start, + ); + if dot(input.right, sub(projected, on_edge)).abs() >= 0.05 { + continue; + } + if point.is_none_or(|old| projected[1] > old[1]) { + *point = Some(projected); + } + } + } + for (descriptor, edge) in batch.secondary.iter().zip(edge_points) { + let hit = results.lines[descriptor.forward_index]; + match (hit, edge) { + (Some(hit), Some(edge)) if edge[1] > hit.position[1] => { + samples.insert(input, edge, input.up, 1, -1., 0); + } + (Some(hit), _) => { + samples.insert(input, hit.position, hit.normal, 1, -1., 0); + } + (None, Some(edge)) => { + samples.insert(input, edge, input.up, 1, -1., 0); + } + (None, None) => {} + } + } + for descriptor in &batch.primary { + for index in [Some(descriptor.forward_index), descriptor.reverse_index] + .into_iter() + .flatten() + { + if let Some(hit) = results.lines[index] { + samples.insert(input, hit.position, hit.normal, 0, -1., 0); + } + } + } + super::profile::sort(&mut samples.ground); + super::profile::sort(&mut samples.other); + samples +} + +/// 8296EBB0 followed by 82E09C80. One inverse-sqrt refinement per input. +fn folded_angle(a: Vector, b: Vector) -> f32 { + let aa = dot(a, a); + let bb = dot(b, b); + let mut angle = 0.; + if aa > f32::from_bits(0x38d1b717) && bb > f32::from_bits(0x38d1b717) { + let unit = |v, square| { + let r = crate::physics::reciprocal_sqrt::estimate(square); + scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.), r)) + }; + angle = crate::trigonometry::acos(dot(unit(a, aa), unit(b, bb)).clamp(-1., 1.)); + } + let turns = angle * f32::from_bits(0x3e22f983); + let fraction = turns - turns.floor(); + let signed = (fraction - if fraction > 0.5 { 1. } else { 0. }) * f32::from_bits(0x40c90fdb); + let sign = if signed <= 0. { -1. } else { 1. }; + let magnitude = signed * sign; + sign * if magnitude > 1.5707964 { + magnitude - 3.1415927 + } else { + magnitude + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/generation.rs b/crates/skate-core/src/player/offboard/contact_toolkit/generation.rs new file mode 100644 index 00000000..b2e27874 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/generation.rs @@ -0,0 +1,170 @@ +//! TU3 82D837C8: ordered rising/falling profile candidate production. +use super::candidate::{Builder, Candidate, between_slope, down_slope, up_slope}; +use super::profile::Profile; +use super::{ContactPrefix, Input, cross, dot, length, scale, sub}; + +pub(super) fn generate( + input: Input, + profile: &Profile, + prefix: &ContactPrefix, + obstruction: f32, + retained_flags: u32, +) -> Vec { + let mut builder = Builder { + input, + profile, + obstruction, + candidates: Vec::new(), + }; + let supported = prefix.flags_176 & 1 != 0; + if supported { + let planar = sub( + input.velocity, + scale(input.up, dot(input.up, input.velocity)), + ); + if length(planar) < 0.01 { + builder.add( + prefix.position, + prefix.normal, + cross(input.right, prefix.normal), + -1, + 0, + 0, + ); + return builder.candidates; + } + } + if profile.last_ground == 0 { + return builder.candidates; + } + let mut highest: Option = None; + let mut have_rise = false; + let mut saw_drop = false; + let forward = |point| dot(sub(point, input.position), input.forward); + for i in 0..profile.last_ground { + let segment = profile.segments[i]; + let normal = if i + 1 < profile.last_ground { + let next = profile.segments[i + 1]; + if segment.kind != 0 && next.kind != 0 { + input.up + } else { + next.normal + } + } else { + segment.normal + }; + if !saw_drop { + let prior = highest.map_or(input.position, |j| profile.segments[j].end); + if dot(sub(segment.end, prior), input.up) > 0. { + highest = Some(i); + } + } + if segment.kind == 1 && segment.length > 0.05 { + let tall = segment.length > 0.4; + let mut combined = false; + let mut eligible = false; + let mut slope = up_slope(input, segment.length); + let mut j = i + 1; + while j < profile.last_ground { + let next = profile.segments[j]; + if !eligible && next.kind == 2 { + eligible = forward(next.start) < obstruction - 0.35; + } + if next.kind != 0 && (next.kind != 1 || next.length >= 0.05) { + break; + } + if !eligible { + eligible = dot(next.direction, input.forward) > 0.70700002 + && forward(next.start) < obstruction - 0.35; + } + j += 1; + } + if j < profile.last_ground && profile.segments[j].kind == 1 { + let next_end = profile.segments[j].end; + let joined = between_slope(input, segment.end, next_end); + if joined > up_slope(input, segment.length) * 0.5 { + slope = joined; + combined = true; + if !have_rise { + combined = between_slope(input, input.position, next_end) + >= up_slope(input, segment.length) * 0.5; + } + } + } + if !have_rise { + have_rise = eligible; + } + if eligible { + builder.approach_up(i, if tall { 0x80 } else { 0 }, slope); + let flags = 0x40 | if combined || !tall { 0 } else { 0x100 }; + builder.point( + segment.end, + normal, + i as i32, + flags, + if combined { 2 } else { 3 }, + ); + } + } else if segment.kind == 2 && segment.length > 0.05 { + let tall = segment.length > 0.4; + let mut combined = false; + let mut slope = -down_slope(input, segment.length); + let mut j = i + 1; + while j < profile.last_ground { + let next = profile.segments[j]; + if next.kind != 0 && !(next.kind == 2 && next.length < 0.05) { + break; + } + j += 1; + } + if j < profile.last_ground && profile.segments[j].kind == 2 { + let joined = between_slope(input, segment.start, profile.segments[j].start); + if joined < down_slope(input, segment.length) * -0.5 { + combined = true; + slope = joined; + } + } + let mut suppress = false; + if !saw_drop && !have_rise { + if let Some(highest) = highest { + suppress = + dot(sub(profile.segments[highest].end, input.position), input.up) > 0.02; + if i == 1 && profile.segments[0].kind == 0 { + suppress = false; + } + } + } + saw_drop = true; + if !suppress { + if combined { + builder.point(segment.start, normal, i as i32, 0x40, 5); + } else if forward(segment.start) <= 0.75 { + builder.approach_down(i as i32, if tall { 0x100 } else { 0 }, slope, 4); + builder.point( + segment.start, + normal, + i as i32, + 0x40 | if tall { 0x80 } else { 0 }, + 6, + ); + } + } + } + } + let gap = input.position[1] - prefix.position[1]; + if supported && gap > 0.01 { + let flags = if gap >= 0.4 { + retained_flags & 0x100 | 0x40 + } else { + retained_flags & 0x140 + }; + if builder.candidates.is_empty() || !saw_drop { + let kind = if builder.candidates.is_empty() { 7 } else { 8 }; + // 82D83F94 loads stock 8208824C=0x3F4CCCCD, not `gap`. + builder.approach_down(-1, flags, -down_slope(input, 0.8), kind); + } + } else if builder.candidates.is_empty() { + builder.advance(length(input.velocity) * 0.016666668); + } + builder.candidates +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/infill.rs b/crates/skate-core/src/player/offboard/contact_toolkit/infill.rs new file mode 100644 index 00000000..f1cb727f --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/infill.rs @@ -0,0 +1,157 @@ +//! Contact-list infill82D82498, normal correction82D82940/82D82AF0. +use super::analyzer_math::{clamp_normal, madd, plane_segment}; +use super::profile::sort; +use super::{ContactSample, Input, Samples, dot, length, scale, sub}; + +pub(super) fn insert_obstacles(input: Input, s: &mut Samples) { + let initial = s.ground.clone(); + for pair in initial.windows(2) { + let (a, b) = (pair[0], pair[1]); + let mut low = a.height.min(b.height); + let mut high = a.height.max(b.height); + let mut i = 0; + while i < s.other.len() { + let p = s.other[i]; + if a.forward_distance > p.forward_distance || p.forward_distance > b.forward_distance { + i += 1; + continue; + } + if low > p.height { + s.other[i].flags |= 2; + i += 1; + continue; + } + if high > p.height + || p.sort_distance < a.sort_distance + || p.sort_distance > b.sort_distance + { + i += 1; + continue; + } + let mut minimum = i; + let mut maximum = i; + s.other[i].flags |= 2; + i += 1; + while i < s.other.len() + && (p.forward_distance - s.other[i].forward_distance).abs() <= 0.001 + { + let q = s.other[i]; + s.other[i].flags |= 2; + if q.height >= high && s.other[minimum].height > q.height { + minimum = i; + } + if q.height > s.other[maximum].height { + maximum = i; + } + i += 1; + } + for index in [minimum, maximum] { + s.other[index].flags = (s.other[index].flags & !2) | 1; + } + let p = s.other[minimum]; + s.insert(input, p.position, p.normal, 1, p.sort_distance, 1); + if p.height > high { + low = high; + high = p.height; + } else if p.height > low { + low = p.height; + } + if minimum != maximum { + let q = s.other[maximum]; + s.insert(input, q.position, q.normal, 1, q.sort_distance, 1); + } + // 82D82498 advances once more at the outer for-loop increment, + // after the equal-forward-distance group has already advanced i. + i += 1; + } + } + sort(&mut s.ground); +} +pub(super) fn correct_normals(input: Input, s: &mut Samples) { + let mut previous = input.position; + for p in &mut s.ground { + let height = dot(sub(p.position, previous), input.up); + p.normal = if height.abs() < 0.001 { + input.up + } else { + clamp_normal( + if height <= 0. { + input.forward + } else { + scale(input.forward, -1.) + }, + input.up, + p.normal, + ) + }; + previous = p.position; + } + let initial = s.ground.clone(); + for pair in initial.windows(2) { + for i in 0..s.other.len() { + let p = s.other[i]; + if p.flags & 3 == 0 + && pair[0].forward_distance <= p.forward_distance + && p.forward_distance <= pair[1].forward_distance + { + infill(input, s, pair[0], pair[1], i); + } + } + } + sort(&mut s.ground); +} +fn infill(input: Input, s: &mut Samples, a: ContactSample, b: ContactSample, index: usize) { + let p = s.other[index]; + if dot(a.normal, b.normal) > 0.99 + && dot(a.normal, p.normal) > 0.99 + && dot(sub(b.position, a.position), b.normal).abs() < 0.01 + { + let mut delta = sub(p.position, a.position); + if length(delta) < 0.01 { + delta = sub(p.position, b.position); + } + if dot(delta, b.normal).abs() < 0.01 { + return; + } + } + let n = clamp_normal( + if b.height > a.height { + scale(input.forward, -1.) + } else { + input.forward + }, + input.up, + p.normal, + ); + let delta = sub(b.position, a.position); + let first_end = madd( + sub(delta, scale(a.normal, dot(a.normal, delta))), + 1.1, + a.position, + ); + let last_start = sub( + b.position, + scale(sub(delta, scale(b.normal, dot(b.normal, delta))), 1.1), + ); + for candidate in [ + plane_segment(p.position, n, a.position, first_end), + plane_segment(p.position, n, last_start, b.position), + ] + .into_iter() + .flatten() + { + let d = sub(candidate, input.position); + let x = dot(input.forward, d); + let y = dot(input.up, d); + if x >= a.forward_distance + && x <= b.forward_distance + && y >= a.height.min(b.height) + && y <= a.height.max(b.height) + { + let t = length(sub(candidate, a.position)) / length(delta); + let order = t * (b.sort_distance - a.sort_distance) + a.sort_distance; + s.insert(input, candidate, n, 1, order, 2); + s.other[index].flags |= 4; + } + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/prefix.rs b/crates/skate-core/src/player/offboard/contact_toolkit/prefix.rs new file mode 100644 index 00000000..32b7645f --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/prefix.rs @@ -0,0 +1,78 @@ +//! Exact semantic fields of the 184-byte prefix copied by82D31040. +use super::{Frame, IDENTITY, Input, UP, Vector, ZERO, dot, sub}; +use crate::air::trajectory::QueryResult; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactPrefix { + pub position: Vector, //0 + pub normal: Vector, //16 + pub support_frame: Frame, //32..95 + pub target_position: Vector, //96 + pub target_normal: Vector, //112 + pub edge_position: Vector, //128 + pub edge_normal: Vector, //144 + pub scalar_160: f32, + pub kind_164: u32, + pub distance_168: f32, + pub distance_172: f32, + pub flags_176: u32, + pub support_180: u32, +} +impl ContactPrefix { + ///82D2DDD0. Note edge normal144 resets to ZERO, not world up. + pub const fn reset() -> Self { + Self { + position: ZERO, + normal: UP, + support_frame: IDENTITY, + target_position: ZERO, + target_normal: UP, + edge_position: ZERO, + edge_normal: ZERO, + scalar_160: 0., + kind_164: 0, + distance_168: 1.0e10, + distance_172: 1.0e10, + flags_176: 0, + support_180: 0, + } + } + pub(super) fn consume_support( + &mut self, + input: Input, + hits: &[QueryResult; 3], + candidate_flags: u32, + ) { + let main = hits[0]; + if main.valid() { + self.position = main.contact_position; + self.normal = main.landing_normal; + self.support_frame = main.contact_transform; + self.flags_176 |= 1; + if dot(input.up, sub(main.contact_position, input.position)) < -0.1 { + self.flags_176 |= 8; + if candidate_flags & 0x40 != 0 { + self.flags_176 |= 0x40; + } + } else { + self.flags_176 &= !8; + } + self.support_180 = main.geometry; + } + for (hit, flag) in [(hits[1], 0x10), (hits[2], 0x20)] { + //82D85660 strict bounds, no contact-time proximity surrogate. + let height = dot(sub(hit.contact_position, input.position), input.up); + if hit.valid() && height < 0.2 && height > -0.2 { + self.flags_176 |= flag; + if main.valid() && hit.landing_normal[1] > self.normal[1] { + self.normal = hit.landing_normal; + } + } + } + } +} +impl Default for ContactPrefix { + fn default() -> Self { + Self::reset() + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/probes.rs b/crates/skate-core/src/player/offboard/contact_toolkit/probes.rs new file mode 100644 index 00000000..d040d869 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/probes.rs @@ -0,0 +1,186 @@ +//! Original descriptors82D856C8 and query geometry82D811C8. +use super::{Vector, cross}; +use crate::air::trajectory::{QueryRequest, Trajectory}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Input { + pub position: Vector, // toolkit0 + pub forward: Vector, //16 + pub up: Vector, //32 + pub right: Vector, //48 + pub velocity: Vector, //64 + pub animation_up: Vector, //80 + pub animation_right: Vector, //96 +} +impl Input { + pub fn from_vectors(v: [Vector; 7]) -> Self { + Self { + position: v[0], + forward: v[1], + up: v[2], + right: v[3], + velocity: v[4], + animation_up: v[5], + animation_right: v[6], + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineProbe { + pub start: Vector, + pub end: Vector, + pub radius: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Descriptor { + pub line: LineProbe, + pub forward_index: usize, + pub reverse_index: Option, +} +#[derive(Clone, Debug, PartialEq)] +pub struct ProbeLayout { + pub trajectories: [LineProbe; 3], + pub secondary: Vec, + pub primary: Vec, +} +#[derive(Clone, Debug)] +pub struct Batch { + pub input: Input, + pub matching_group: i32, + /// Original request mesh184 rejection mask. Not a surface-material mask. + pub mesh_reject_mask: u32, + pub trajectories: [QueryRequest; 3], + pub lines: Vec, + pub secondary: Vec, + pub primary: Vec, +} +fn probe(x0: f32, y0: f32, z0: f32, x1: f32, y1: f32, z1: f32, radius: f32) -> LineProbe { + LineProbe { + start: [x0, y0, z0, 0.], + end: [x1, y1, z1, 0.], + radius, + } +} +impl ProbeLayout { + pub fn stock() -> Self { + // Live TU3 data8303744C = 3f4ccccd (0.8); not a tuned foot height. + let trajectories = [ + probe(0., 1.2, 0., 0., -0.8, 0., 0.01), + probe(-0.12, 0.8, 0., -0.17, -0.2, 0., 0.03), + probe(0.12, 0.8, 0., 0.17, -0.2, 0., 0.03), + ]; + let mut secondary = Vec::new(); + let mut index = 0; + let mut z = 0.037500001_f32; + loop { + secondary.push(Descriptor { + line: probe(0., 0.8, z, 0., -0.8, z, 0.), + forward_index: index, + reverse_index: None, + }); + index += 1; + z += 0.075000003; + if !(z < 1.7624999) { + break; + } + } + let mut primary = Vec::new(); + for i in 0..12 { + // Preserve the source's two products and subtraction. + let fraction = i as f32 * 0.090909094; + let height = fraction * 0.8 - (1. - fraction) * 0.8; + let reverse_index = if height < 0. { Some(index + 1) } else { None }; + primary.push(Descriptor { + line: probe(0., height, 0., 0., height, 1.8, 0.), + forward_index: index, + reverse_index, + }); + index += if reverse_index.is_some() { 2 } else { 1 }; + } + for i in 0..3 { + let height = (i as f32 * 2. + 1.) * 0.099999994 + 0.8; + primary.push(Descriptor { + line: probe(0., height, 0.099999994, 0., height, 1.6999999, 0.099999994), + forward_index: index, + reverse_index: None, + }); + index += 1; + } + Self { + trajectories, + secondary, + primary, + } + } + pub fn prepare(&self, input: Input, matching_group: i32) -> Batch { + let surface = [input.right, input.up, input.forward, input.position]; + // 82D81298..12E4 computes animation_right x animation_up. + let animated = [ + input.animation_right, + input.animation_up, + cross(input.animation_right, input.animation_up), + input.position, + ]; + let trajectories = std::array::from_fn(|i| { + let line = transform( + self.trajectories[i], + if i == 0 { surface } else { animated }, + ); + QueryRequest { + trajectory: Trajectory { + position: line.start, + velocity: super::sub(line.end, line.start), + acceleration: [0.; 4], + duration: 1., + }, + radius: line.radius, + start_error: 0., + end_error: 0., + } + }); + let mut lines = Vec::new(); + let mut prepare = |descriptors: &[Descriptor]| { + descriptors + .iter() + .map(|d| { + let line = transform(d.line, surface); + lines.push(line); + if d.reverse_index.is_some() { + lines.push(LineProbe { + start: line.end, + end: line.start, + radius: line.radius, + }); + } + Descriptor { line, ..*d } + }) + .collect() + }; + let secondary = prepare(&self.secondary); + let primary = prepare(&self.primary); + Batch { + input, + matching_group, + mesh_reject_mask: 0x6000, + trajectories, + lines, + secondary, + primary, + } + } +} +fn transform(line: LineProbe, frame: [Vector; 4]) -> LineProbe { + let point = |v: Vector| { + std::array::from_fn(|i| { + frame[2][i].mul_add( + v[2], + frame[1][i].mul_add(v[1], frame[0][i].mul_add(v[0], frame[3][i])), + ) + }) + }; + LineProbe { + start: point(line.start), + end: point(line.end), + radius: line.radius, + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/profile.rs b/crates/skate-core/src/player/offboard/contact_toolkit/profile.rs new file mode 100644 index 00000000..8e0f25d4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/profile.rs @@ -0,0 +1,109 @@ +//! Profile cleanup82D830A8 and segment construction82D83438. +use super::analyzer_math::{normalize, reciprocal}; +use super::{ContactSample, Input, Vector, cross, dot, length, scale, sub}; +#[derive(Clone, Copy, Debug)] +pub(super) struct Segment { + pub kind: u32, + pub start: Vector, + pub end: Vector, + pub direction: Vector, + pub normal: Vector, + pub length: f32, +} +#[derive(Clone, Debug, Default)] +pub(super) struct Profile { + pub segments: Vec, + pub last_ground: usize, +} +pub(super) fn simplify(input: Input, points: &mut Vec) -> (f32, usize) { + let mut obstruction = 1e10; + if points.len() < 2 { + return (obstruction, points.len()); + } + let mut count = points.len(); + let removed = points.last().unwrap().sort_distance + 1.; + for i in 1..points.len() - 1 { + if points[i].flags & 8 != 0 { + count -= points.len() - 1 - i; + obstruction = dot(sub(points[i].position, input.position), input.forward); + break; + } + let incoming = [ + points[i].forward_distance - points[i - 1].forward_distance, + points[i].height - points[i - 1].height, + 0., + 0., + ]; + let outgoing = [ + points[i + 1].forward_distance - points[i].forward_distance, + points[i + 1].height - points[i].height, + 0., + 0., + ]; + let a = length(incoming); + let b = length(outgoing); + let discard = a < 0.02 + || b < 0.02 + || (!(dot(incoming, outgoing) < 0. && dot(incoming, input.up) >= 0.) + && length(cross( + scale(incoming, reciprocal(a)), + scale(outgoing, reciprocal(b)), + )) < 0.05); + if discard { + points[i].sort_distance = removed; + count -= 1; + } + } + sort(points); + // Native count14928 shrinks, but backing records survive for the later + // publication scan bounded by retained original count14932. + (obstruction, count) +} +pub(super) fn sort(points: &mut [ContactSample]) { + points.sort_by(|a, b| { + a.sort_distance + .partial_cmp(&b.sort_distance) + .unwrap_or(std::cmp::Ordering::Equal) + }); +} +pub(super) fn build(input: Input, points: &[ContactSample]) -> Profile { + let mut out = Profile::default(); + for pair in points.windows(2) { + let start = pair[0].position; + let end = pair[1].position; + let delta = sub(end, start); + let distance = length(delta); + if distance < 0.0001 { + continue; + } + let direction = scale(delta, reciprocal(distance)); + let kind = if dot(input.forward, direction).abs() > 0.5 { + 0 + } else if dot(input.up, direction) >= 0. { + 1 + } else { + 2 + }; + if kind != 0 && out.segments.last().is_some_and(|last| last.kind == kind) { + let last = out.segments.last_mut().unwrap(); + last.end = end; + let delta = sub(end, last.start); + last.length = length(delta); + last.direction = scale(delta, reciprocal(last.length)); + last.normal = normalize(cross(delta, input.right), input.up); + } else { + out.segments.push(Segment { + kind, + start, + end, + direction, + normal: normalize(cross(delta, input.right), input.up), + length: distance, + }); + if kind == 0 { + out.last_ground = out.segments.len(); + } + } + } + out +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/publication.rs b/crates/skate-core/src/player/offboard/contact_toolkit/publication.rs new file mode 100644 index 00000000..4a0e38c0 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/publication.rs @@ -0,0 +1,109 @@ +//! TU3 82D84060: profile lookahead, candidate selection and prefix publication. +use super::analyzer_math::madd; +use super::candidate::Candidate; +use super::classification::History; +use super::profile::Profile; +use super::{ContactPrefix, Input, Samples, dot, length, sub}; + +pub(super) fn publish( + input: Input, + profile: &Profile, + samples: &Samples, + candidates: &mut [Candidate], + obstruction: f32, + retained: &mut Candidate, + history: &mut History, + prefix: &mut ContactPrefix, +) { + prefix.edge_position = input.position; + prefix.edge_normal = input.up; + prefix.distance_168 = 0.; + if profile.last_ground != 0 { + let end = profile.segments[profile.last_ground - 1].end; + let reach = dot(sub(end, input.position), input.forward); + prefix.distance_168 = if reach >= 1.65 { 1e10 } else { reach }; + if let Some(segment) = profile + .segments + .iter() + .find(|s| (s.kind == 1 || s.kind == 2) && s.length > 0.05) + { + prefix.distance_168 = dot(sub(segment.start, input.position), input.forward); + } + if prefix.flags_176 & 1 != 0 && input.forward[1] > 0.2 && prefix.normal[1] < 0.7 { + for points in [ + &samples.ground[..samples.original_ground_count.min(samples.ground.len())], + &samples.other[..], + ] { + if let Some(point) = points + .iter() + .find(|p| p.normal[1] > 0.95 || p.flags & 0x10 != 0) + { + if dot(sub(point.position, input.position), input.up) > 0. { + prefix.flags_176 |= 0x200; + } + } + } + } + let mut distance = length(input.velocity) * 0.25; + for segment in &profile.segments { + if segment.kind != 0 && segment.normal[1] <= 0.9 { + continue; + } + prefix.edge_normal = segment.normal; + distance -= segment.length; + if distance <= 0. { + prefix.edge_position = madd(segment.direction, distance, segment.end); + break; + } + prefix.edge_position = segment.end; + prefix.flags_176 |= 4; + } + } + candidates.sort_by(|a, b| { + a.order + .partial_cmp(&b.order) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if candidates.is_empty() { + if obstruction >= 0.3 { + prefix.flags_176 &= !1; + } else { + prefix.flags_176 |= 0x400; + } + return; + } + let step = length(input.velocity) * 0.016666668; + let mut selected = candidates.len() - 1; + for (index, candidate) in candidates.iter().enumerate() { + let behind = index + 1 < candidates.len() + && dot(input.forward, sub(candidate.position, input.position)) < step; + if candidate.kind == 1 { + let overridden = behind + || candidates[index + 1..] + .iter() + .take_while(|next| next.low <= candidate.high) + .any(|next| matches!(next.kind, 4 | 7 | 8)); + if overridden { + selected = candidates[index + 1..] + .iter() + .position(|next| next.segment == candidate.segment) + .map_or(index, |offset| index + 1 + offset); + break; + } + } + if !behind { + selected = index; + break; + } + } + *retained = candidates[selected]; + prefix.target_position = retained.position; + prefix.target_normal = retained.normal; + prefix.flags_176 |= retained.flags | 2; + if prefix.flags_176 & 1 == 0 { + prefix.flags_176 |= 0x3b; + prefix.position = input.position; + prefix.normal = input.up; + } + history.classify(prefix, retained.direction); +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/samples.rs b/crates/skate-core/src/player/offboard/contact_toolkit/samples.rs new file mode 100644 index 00000000..ead1d7e2 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/samples.rs @@ -0,0 +1,83 @@ +//! Contact projection/classification82D81F80. No synthesized floor samples. +use super::{Input, Vector, dot, length, scale, sub}; +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactSample { + pub position: Vector, + pub normal: Vector, + pub forward_distance: f32, + pub height: f32, + pub flags: u32, + pub sort_distance: f32, +} +#[derive(Clone, Debug, Default)] +pub struct Samples { + pub ground: Vec, //10832, capacity64 + pub other: Vec, //14944 + pub original_ground_count: usize, //14932 +} +impl Samples { + pub fn insert( + &mut self, + input: Input, + position: Vector, + normal: Vector, + category: u32, + sort_override: f32, + provenance: u32, + ) -> bool { + let delta = sub(position, input.position); + let forward = dot(input.forward, delta); + let height = dot(input.up, delta); + let high = height >= 0.79000002 && forward >= 0.; + let normal = sub(normal, scale(input.right, dot(normal, input.right))); + let magnitude = length(normal); + let normal = if magnitude >= 0.001 { + scale(normal, reciprocal(magnitude)) + } else if height < 0. { + input.forward + } else { + scale(input.forward, -1.) + }; + let ground = category == 1 || high; + if ground && self.ground.len() == 64 || !ground && forward < 0. { + return false; + } + let mut flags = match provenance { + 1 => 1, + 2 => 4, + _ => 0, + }; + if high { + flags |= 8; + } + if category == 2 { + flags |= 0x10; + } + let sample = ContactSample { + position, + normal, + forward_distance: forward, + height, + flags, + sort_distance: if sort_override >= 0. { + sort_override + } else { + forward + }, + }; + if ground { + self.ground.push(sample); + self.original_ground_count = self.ground.len(); + } else { + self.other.push(sample); + } + true + } +} +fn reciprocal(value: f32) -> f32 { + let mut r = crate::physics::native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(value, 1.), r); + } + r +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/sweep.rs b/crates/skate-core/src/player/offboard/contact_toolkit/sweep.rs new file mode 100644 index 00000000..ed12980c --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/sweep.rs @@ -0,0 +1,98 @@ +//! The toolkit's zero-acceleration, duration-one subset of82770910/8276C558. +//! The query remains a swept sphere. A returned point is a surface contact, +//! not the sphere centre or an unadjusted ray intersection. +use super::{LineHit, LineProbe, UP, Vector, ZERO, cross, dot, length, scale, sub}; +use crate::air::trajectory::QueryResult; + +pub fn query_sweep( + probe: LineProbe, + mut line: impl FnMut(LineProbe) -> Result, E>, + mut nearby: impl FnMut(Vector, f32) -> Result, E>, +) -> Result { + let velocity = sub(probe.end, probe.start); + //82770910 minimum squared distance is zero for both error scalars zero. + if !(dot(velocity, velocity) > 0.) + || !velocity[..3] + .iter() + .any(|x| x.abs() > f32::from_bits(0x37800000)) + { + return Ok(QueryResult::miss()); + } + let Some(hit) = line(probe)? else { + return Ok(QueryResult::miss()); + }; + let distance = length(sub(hit.position, probe.start)); + let speed = length(velocity); + let epsilon = f32::from_bits(0x38d1b717); + let frames = if distance.abs() > epsilon && speed.abs() > epsilon { + reciprocal(speed) * distance + } else { + 0. + }; + //8276C794 vrfim is floor, followed by vcfpsxws. This zero-acceleration + //branch differs from the ceiling helper used in the accelerated branch. + let contact_frame = frames.floor() as i32; + let triangles = nearby(hit.position, probe.radius)?; + Ok(QueryResult { + contact_position: hit.position, + contact_normal: hit.normal, + landing_normal: landing_normal(&triangles, velocity), + contact_time: frames * f32::from_bits(0x3c888889), + contact_frame, + contact_transform: hit.mesh_frame, + surface: u32::from(hit.surface), + geometry: hit.geometry, + }) +} +fn reciprocal(value: f32) -> f32 { + let mut r = crate::physics::native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(value, 1.), r); + } + r +} +fn inverse_length(square: f32) -> f32 { + let mut r = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + r = (r * 0.5).mul_add((-square).mul_add(r * r, 1.), r); + } + r +} +///82771018 chooses the most upward eligible face, then averages faces whose +///dot with that face is strictly greater than0.5, retaining traversal order. +fn landing_normal(triangles: &[[Vector; 3]], velocity: Vector) -> Vector { + let mut normals = Vec::with_capacity(64); + let mut best = ZERO; + let mut best_y = -1.; + for [a, b, c] in triangles.iter().take(64) { + let n = cross(sub(*b, *a), sub(*c, *a)); + let n = scale(n, inverse_length(dot(n, n))); + if n[1] > 0.7 || dot(n, velocity) <= 0. { + if n[1] > best_y { + best = n; + best_y = n[1]; + } + normals.push(n); + } + } + if normals.is_empty() { + return UP; + } + let mut total = ZERO; + for n in normals { + if dot(best, n) > 0.5 { + for i in 0..4 { + total[i] += n[i]; + } + } + } + let square = dot(total, total); + let inverse = inverse_length(square); + let magnitude = if square == 0. { 0. } else { square * inverse }; + //830BD350 is initialized by82F826F8 from82181A88=358637bd. + if magnitude > f32::from_bits(0x358637bd) { + scale(total, inverse) + } else { + ZERO + } +} diff --git a/crates/skate-core/src/player/offboard/contact_toolkit/tests.rs b/crates/skate-core/src/player/offboard/contact_toolkit/tests.rs new file mode 100644 index 00000000..0501b3cd --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_toolkit/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::air::trajectory::QueryResult; +fn input() -> Input { + Input::from_vectors([ + ZERO, + [0., 0., 1., 0.], + UP, + [1., 0., 0., 0.], + ZERO, + UP, + [1., 0., 0., 0.], + ]) +} +struct EmptyScene; +impl Scene for EmptyScene { + type Error = (); + fn execute(&self, batch: &Batch) -> Result { + Ok(QueryResults { + trajectories: [QueryResult::miss(); 3], + lines: vec![None; batch.lines.len()], + edges: Vec::new(), + }) + } +} +#[test] +fn empty_world_refresh_cannot_publish_walkable_support() { + let mut owner = Owner::default(); + assert!(owner.refresh().is_none()); + owner.submit(input(), -1, &EmptyScene).unwrap(); + assert!(owner.has_pending()); + let result = owner.refresh().unwrap(); + assert_eq!(result.prefix.flags_176 & 3, 0); + assert_eq!(result.prefix.support_180, 0); + assert_eq!(result.prefix.distance_172, 1e10); + assert!(!owner.has_pending()); + assert!(owner.refresh().is_none()); +} +#[test] +fn classification_requires_three_prior_matches_and_flat_clears_history() { + let mut history = classification::History::default(); + let mut prefix = ContactPrefix::reset(); + prefix.flags_176 = 0x40; + for _ in 0..3 { + history.classify(&mut prefix, [0., 0.3, 1., 0.]); + assert_eq!(prefix.kind_164, 0); + } + history.classify(&mut prefix, [0., 0.3, 1., 0.]); + assert_eq!(prefix.kind_164, 1); + history.classify(&mut prefix, [0., 0., 1., 0.]); + assert_eq!(prefix.kind_164, 0); + history.classify(&mut prefix, [0., 0.3, 1., 0.]); + assert_eq!(prefix.kind_164, 0); +} +#[test] +fn source_tangent_polynomial_has_reasonable_stock_angle_outputs() { + for degrees in [6., 15., 30., 32., 35., 50.] { + let angle: f32 = degrees * 0.017453292; + assert!((analyzer_math::tangent(angle) - angle.tan()).abs() < 0.00001); + } +} +#[test] +fn secondary_edge_is_selected_only_when_higher_than_real_line_contact() { + let layout = ProbeLayout::stock(); + let batch = layout.prepare(input(), -1); + let mut result = EmptyScene.execute(&batch).unwrap(); + result.edges.push([[0., 0.1, -1., 0.], [0., 0.1, 2., 0.]]); + let descriptor = batch.secondary[0]; + let mut position = descriptor.line.start; + position[1] = 0.2; + result.lines[descriptor.forward_index] = Some(LineHit { + position, + normal: UP, + fraction: 0.375, + surface: 17, + mesh_frame: IDENTITY, + geometry: 73, + }); + let samples = collection::collect(&batch, &layout, &result); + assert_eq!(samples.ground[0].position, position); + assert!( + samples.ground[1..] + .iter() + .all(|point| (point.position[1] - 0.1).abs() < 1e-6) + ); +} +#[test] +fn stock_probe_counts_order_and_reverse_indices() { + let layout = ProbeLayout::stock(); + assert_eq!(layout.secondary.len(), 23); + assert_eq!(layout.primary.len(), 15); + let batch = layout.prepare(input(), 27); + assert_eq!(batch.lines.len(), 44); + assert_eq!(batch.matching_group, 27); + assert_eq!(batch.mesh_reject_mask, 0x6000); + for d in &batch.primary { + assert_eq!(batch.lines[d.forward_index], d.line); + if let Some(i) = d.reverse_index { + assert_eq!(batch.lines[i].start, d.line.end); + assert_eq!(batch.lines[i].end, d.line.start); + } + } + assert_eq!(batch.trajectories[0].trajectory.position, [0., 1.2, 0., 0.]); + assert_eq!(batch.trajectories[0].trajectory.velocity, [0., -2., 0., 0.]); + assert_eq!(batch.trajectories[0].radius, 0.01); +} +#[test] +fn side_probes_follow_animation_basis_but_centre_follows_surface() { + let mut i = input(); + i.animation_right = [0., 0., -1., 0.]; + let batch = ProbeLayout::stock().prepare(i, -1); + assert_eq!(batch.trajectories[0].trajectory.position, [0., 1.2, 0., 0.]); + assert_eq!( + batch.trajectories[1].trajectory.position, + [0., 0.8, 0.12, 0.] + ); + assert_eq!( + batch.trajectories[2].trajectory.position, + [0., 0.8, -0.12, 0.] + ); +} +#[test] +fn prefix_reset_does_not_invent_support_or_reach() { + let p = ContactPrefix::reset(); + assert_eq!(p.flags_176, 0); + assert_eq!(p.kind_164, 0); + assert_eq!(p.distance_168, 1e10); + assert_eq!(p.distance_172, 1e10); + assert_eq!(p.edge_normal, ZERO); +} +#[test] +fn support_uses_refined_normal_and_mesh_frame_and_strict_side_window() { + let mut hits = [QueryResult::miss(); 3]; + hits[0] = QueryResult { + contact_time: 0., + contact_position: [0., -0.11, 0., 0.], + geometry: 92, + ..QueryResult::miss() + }; + hits[1] = QueryResult { + contact_time: 0., + contact_position: [0., 0.2, 0., 0.], + ..QueryResult::miss() + }; + let mut p = ContactPrefix::reset(); + p.consume_support(input(), &hits, 0x40); + assert_eq!(p.flags_176, 0x49); + assert_eq!(p.support_180, 92); + hits[1].contact_position[1] = 0.199; + p = ContactPrefix::reset(); + p.consume_support(input(), &hits, 0); + assert_eq!(p.flags_176, 0x19); +} +#[test] +fn projected_contacts_are_bounded_and_reject_behind_obstacles() { + let mut samples = Samples::default(); + assert!(!samples.insert(input(), [0., 0., -1., 0.], UP, 0, -1., 0)); + for n in 0..64 { + assert!(samples.insert(input(), [0., 0., n as f32, 0.], UP, 1, -1., 0)); + } + assert!(!samples.insert(input(), ZERO, UP, 1, -1., 0)); + assert_eq!(samples.original_ground_count, 64); +} +#[test] +fn sweep_contact_time_is_not_the_line_fraction() { + let p = LineProbe { + start: [0., 1., 0., 0.], + end: [0., -1., 0., 0.], + radius: 0.01, + }; + let hit = LineHit { + position: ZERO, + normal: UP, + fraction: 0.495, + surface: 0, + mesh_frame: IDENTITY, + geometry: 7, + }; + let r = query_sweep(p, |_| Ok::<_, ()>(Some(hit)), |_, _| Ok(vec![])).unwrap(); + assert!((r.contact_time - 0.5 / 60.).abs() < 1e-7); + assert_eq!(r.contact_frame, 0); + assert_eq!(r.geometry, 7); +} diff --git a/crates/skate-core/src/player/offboard/controller.rs b/crates/skate-core/src/player/offboard/controller.rs index e81fefe5..0d69a043 100644 --- a/crates/skate-core/src/player/offboard/controller.rs +++ b/crates/skate-core/src/player/offboard/controller.rs @@ -81,11 +81,14 @@ impl Controller { self.state.place(input); } pub fn step_ground(&mut self, job: &GroundJob) -> GroundResult { - step::update(&mut self.state, &self.settings, job); - output::export(&self.state) - } - ///82D80F48 publishes current fields without advancing the controller. - pub fn output(&self) -> GroundResult { - output::export(&self.state) + step_ground(&mut self.state, &self.settings, job) } } + +/// Run the recovered ground producer against an externally owned state. The +/// game coordinator owns the state lifetime; this keeps the numerical producer +/// reusable without constructing a second controller every tick. +pub fn step_ground(state: &mut State, settings: &Settings, job: &GroundJob) -> GroundResult { + step::update(state, settings, job); + output::export(state) +} diff --git a/crates/skate-core/src/player/offboard/grab_scene.rs b/crates/skate-core/src/player/offboard/grab_scene.rs new file mode 100644 index 00000000..59c55fd1 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene.rs @@ -0,0 +1,60 @@ +//! Original S3 grab-scene records and execution, separate from pending queries. +//! Ground owns request readiness, validation, selection and binding. +pub mod record; +pub mod registry; +mod query; +mod math; +mod qualify; +pub use qualify::{qualify,closest_point,best_spline}; +pub use query::query; +pub use record::{AssemblyData, Geometry, RecordInput}; +pub use registry::{Object, Provider, Registry, Spline}; +use super::{ground_query::QueryContext, ground_sync::{Bounds, BoardLimits}}; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Descriptor { pub kind: u32, pub id: u32 } + +/// Lossless native288-byte value. Word196 is a geometry identity, not readiness. +#[derive(Clone, Debug, PartialEq)] +pub struct Record(pub [u32; 72], pub std::sync::Arc); +impl Record { + pub fn descriptor(&self) -> Descriptor { Descriptor { kind: self.0[47], id: self.0[48] } } + pub fn endpoints(&self) -> [Vector; 2] { [self.vector(64), self.vector(80)] } + pub fn assembly(&self) -> Option { (self.0[52] != 0).then_some(self.0[52]) } + pub fn valid(&self) -> bool { self.0[49] != 0 } + pub fn points(&self) -> &[Vector] { &self.1.points } + pub fn vector(&self, offset: usize) -> Vector { + std::array::from_fn(|i| f32::from_bits(self.0[offset / 4 + i])) + } + pub fn scalar(&self, offset: usize) -> f32 { f32::from_bits(self.0[offset / 4]) } + pub fn byte(&self, offset: usize) -> u8 { + self.0[offset / 4].to_be_bytes()[offset % 4] + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Query { + pub position: Vector, + ///82760508 sorts at+96, distinct from provider position+80. + pub sort_position: Vector, + pub bounds: Bounds, + pub limits: BoardLimits, + pub mode: u32, + pub capacity: usize, + pub context: QueryContext, +} + +#[derive(Clone, Copy, Debug)] +pub struct Line { + pub start: Vector, pub end: Vector, pub radius: f32, + pub group: i32, pub reject_flags: u32, + ///Batch source pools: validation82D744F8 uses7; interactable8275FD00 uses2. + pub source_pool_mask:u8, + ///Actor virtual56: NOT a collision-mesh rejection mask. + pub selection_flags:u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Hit { pub fraction: f32, pub assembly: Option } diff --git a/crates/skate-core/src/player/offboard/grab_scene/math.rs b/crates/skate-core/src/player/offboard/grab_scene/math.rs new file mode 100644 index 00000000..cba272f2 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/math.rs @@ -0,0 +1,33 @@ +use super::{Frame, Vector}; +pub(super) fn dot(a: Vector,b: Vector)->f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub(super) fn sub(a:Vector,b:Vector)->Vector { std::array::from_fn(|i|a[i]-b[i]) } +pub(super) fn madd(a:Vector,k:f32,b:Vector)->Vector { std::array::from_fn(|i|a[i].mul_add(k,b[i])) } +pub(super) fn length(v:Vector)->f32 { + let d=dot(v,v); + let mut r=d.sqrt().recip(); + for _ in 0..2 { r=(r*0.5).mul_add((-d).mul_add(r*r,1.0),r); } + if d==0.0 {0.0} else {d*r} +} +pub(super) fn reciprocal(v:f32)->f32 { + let mut r=v.recip(); + for _ in 0..2 {r=r.mul_add((-r).mul_add(v,1.0),r);} + r +} +pub(super) fn point(f:Frame,v:Vector)->Vector { + madd(f[2],v[2],madd(f[1],v[1],madd(f[0],v[0],f[3]))) +} +pub(super) fn direction(f:Frame,v:Vector)->Vector { + madd(f[2],v[2],madd(f[1],v[1],f[0].map(|x|x*v[0]))) +} +///82ADD910 ->82ADDB48 positive-result branch, fatness zero. +pub(super) fn sphere_segment(center:Vector,radius:f32,start:Vector,end:Vector)->bool { + let c=sub(center,start);let rr=radius*radius; + if dot(c,c)0.0) {return false;} + let dd=dot(d,d); + let cross=[(-c[2]).mul_add(d[1],c[1]*d[2]), + (-c[0]).mul_add(d[2],c[2]*d[0]),(-c[1]).mul_add(d[0],c[0]*d[1]),0.0]; + let discriminant=dd*rr-dot(cross,cross);let beyond=projection-dd; + !(discriminant<0.0 || (beyond>0.0 && beyond*beyond>discriminant)) +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/qualify.rs b/crates/skate-core/src/player/offboard/grab_scene/qualify.rs new file mode 100644 index 00000000..00bcebff --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/qualify.rs @@ -0,0 +1,51 @@ +//! S3 CanGrabSpline82E08DB8/82E08EE8 and actual polyline callees. +//! S2 82E4B4F8/82E4A9A8 corroborate projection, angles and box, but lack +//! S3's horizontal-facing/approach gates. No endpoints-only spline substitute. +mod math; +mod polyline; +mod selection; +#[cfg(test)] mod tests; +pub use selection::best_spline; +use super::Record; +use crate::player::offboard::ground_sync::{Bounds,BoardLimits}; +use math::*; +pub use math::closest_point; + +pub fn qualify(record: &Record, position: Vector, bounds: Bounds, + limits: BoardLimits) -> bool { + let length = record.scalar(176); + let mut margin = limits.margin; + if margin > length*0.5 { margin = length*0.5; } + let distance = polyline::nearest_distance(record,position) + .max(margin).min(length-margin); + let point = polyline::at_distance(record,distance); + let endpoints = record.endpoints(); + let direction = sub(endpoints[0],endpoints[1]); + let forward = bounds.frame[2]; + let facing = dot(normalize(flatten(forward)),normalize(flatten(direction))).abs(); + let side = normalize(cross([0.,1.,0.,0.],direction)); + let approach = record.vector(96); + let approach_side = dot(side,approach); + //82E0915C/16C scalar skip branches, followed by ordered angular test. + if approach_side.abs() <= 0.1 || dot(forward,side)*approach_side >= 0. + || !(facing < 0.8) { return false; } + let reach = flatten(sub(point,position)); + if !(dot(reach,reach) > 0.) { return false; } + if !(limits.angle_a > angle(reach,approach.map(|v| -v))) { return false; } + //82E09228..2A0 chooses a real neighboring point along the full polyline. + let mut step = length*0.5; + if step > f32::from_bits(0x3c23d70a) { step = f32::from_bits(0x3c23d70a); } + let next_distance = if distance+step > length { distance-step } else { distance+step }; + let tangent = sub(polyline::at_distance(record,next_distance),point); + let horizontal = flatten(tangent); + if !(dot(horizontal,horizontal) > f32::from_bits(0x37800000)) { return false; } + let turns = angle(tangent,horizontal)*f32::from_bits(0x3e22f983); + let fraction = turns-turns.floor(); + let mut slope = ((fraction-if fraction>0.5 {1.} else {0.}) + *f32::from_bits(0x40c90fdb)).abs(); + if slope > f32::from_bits(0x3fc90fdb) { slope = f32::from_bits(0x40490fdb)-slope; } + if !(limits.angle_b > slope) { return false; } + //82E093E8 rigid inverse followed by inclusive bounds on all XYZ axes. + let local = inverse_point(bounds.frame,point); + (0..3).all(|i| local[i]>=-bounds.extents[i] && local[i]<=bounds.extents[i]) +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/qualify/math.rs b/crates/skate-core/src/player/offboard/grab_scene/qualify/math.rs new file mode 100644 index 00000000..1373b5a8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/qualify/math.rs @@ -0,0 +1,60 @@ +//! Scalar forms of original VMX steps; independent PC estimates, not bit parity. +pub type Vector = [f32;4]; +pub fn dot(a: Vector,b: Vector)->f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub fn sub(a: Vector,b: Vector)->Vector { std::array::from_fn(|i| a[i]-b[i]) } +pub fn flatten(mut a: Vector)->Vector { a[1]=0.;a } +pub fn cross(a: Vector,b: Vector)->Vector { + [(-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]), + (-a[1]).mul_add(b[0],a[0]*b[1]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inv_sqrt(value:f32,steps:usize)->f32 { + let mut r=crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..steps { r=(r*0.5).mul_add((-value).mul_add(r*r,1.),r); } + r +} +pub fn reciprocal(value:f32)->f32 { + let mut r=1./value; + for _ in 0..2 {r=r.mul_add((-value).mul_add(r,1.),r);} + r +} +pub fn length(v:Vector)->f32 { + let squared=dot(v,v); + let result=squared*inv_sqrt(squared,2); + if squared==0. {0.} else {result} +} +pub fn normalize(v:Vector)->Vector { + let squared=dot(v,v); + let r=inv_sqrt(squared,2); + let length=if squared==0. {0.} else {squared*r}; + if length>f32::from_bits(0x358637bd) {v.map(|x| x*r)} else {[0.;4]} +} +///8296EBB0: separate squared-length guards and ONE refinement before acos. +pub fn angle(a:Vector,b:Vector)->f32 { + let aa=dot(a,a);let bb=dot(b,b); + if !(aa>f32::from_bits(0x38d1b717) && bb>f32::from_bits(0x38d1b717)) {return 0.;} + let a=a.map(|x| x*inv_sqrt(aa,1)); + let b=b.map(|x| x*inv_sqrt(bb,1)); + crate::trigonometry::acos(dot(a,b).max(-1.).min(1.)) +} +///82E09D28 retains unnormalized direction below its native LENGTH threshold. +pub fn closest_point(position:Vector,[a,b]:[Vector;2])->Vector { + let mut direction=sub(b,a); + let len=length(direction); + if len>f32::from_bits(0x37800000) {let r=reciprocal(len);direction=direction.map(|v|v*r);} + let along=dot(direction,sub(position,a)).min(len).max(0.); + std::array::from_fn(|i| direction[i].mul_add(along,a[i])) +} +pub fn transform(frame:[Vector;4],point:Vector)->Vector { + std::array::from_fn(|i| frame[2][i].mul_add(point[2], + frame[1][i].mul_add(point[1],frame[0][i].mul_add(point[0],frame[3][i])))) +} +pub fn inverse_point(frame:[Vector;4],point:Vector)->Vector { + let mut result=[0.;4]; + for i in 0..3 { + let translation=frame[i][0].mul_add(-frame[3][0], + frame[i][1].mul_add(-frame[3][1],frame[i][2]*-frame[3][2])); + result[i]=frame[i][2].mul_add(point[2],frame[i][1].mul_add(point[1], + frame[i][0].mul_add(point[0],translation))); + } + result +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/qualify/polyline.rs b/crates/skate-core/src/player/offboard/grab_scene/qualify/polyline.rs new file mode 100644 index 00000000..c30782b4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/qualify/polyline.rs @@ -0,0 +1,50 @@ +//! Original82D2CFE8 nearest distance and82D2D2B0 arc-length evaluation. +use super::{Record,math::*}; +fn frame(r:&Record)->[Vector;4] {[r.vector(0),r.vector(16),r.vector(32),r.vector(48)]} +fn indices(r:&Record)->impl Iterator { + let count=r.points().len(); + let reverse=r.byte(200)&0x20!=0; + (0..count).map(move |i| if reverse {count-1-i} else {i}) +} +pub(super) fn nearest_distance(r:&Record,world:Vector)->f32 { + if r.points().len()<=1 {return 0.;} + let local=inverse_point(frame(r),world); + let mut indices=indices(r); + let mut a=r.points()[indices.next().expect("nonempty source spline")]; + let mut accumulated=0.;let mut best=f32::MAX;let mut distance=0.; + for index in indices { + let b=r.points()[index]; + let near=closest_point(local,[a,b]); + let error=sub(local,near);let squared=dot(error,error); + if best>squared { + best=squared; + let partial=sub(near,a); + //82D2D204 zero mask applies AFTER adding accumulated distance. + distance=if dot(partial,partial)==0. {0.} else {accumulated+length(partial)}; + } + let segment=sub(b,a); + //82D2D260 has the same post-add zero selection. + accumulated=if dot(segment,segment)==0. {0.} else {accumulated+length(segment)}; + a=b; + } + distance +} +pub(super) fn at_distance(r:&Record,distance:f32)->Vector { + let endpoints=r.endpoints(); + if r.points().len()<=1 || 0.>=distance {return endpoints[0];} + let mut indices=indices(r); + let mut a=r.points()[indices.next().expect("nonempty source spline")]; + let mut accumulated=0.; + for index in indices { + let b=r.points()[index];let direction=sub(b,a);let len=length(direction); + accumulated+=len; + if accumulated>distance { + if 0.>=len {return transform(frame(r),b);} + let remaining=reciprocal(len)*(accumulated-distance); + let local=std::array::from_fn(|i| b[i]-direction[i]*remaining); + return transform(frame(r),local); + } + a=b; + } + endpoints[1] +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/qualify/selection.rs b/crates/skate-core/src/player/offboard/grab_scene/qualify/selection.rs new file mode 100644 index 00000000..f7a68851 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/qualify/selection.rs @@ -0,0 +1,44 @@ +//! Original S3 82D4D150/82E09D28; S2 82D97D68 lacks S3 eligibility tests. +//! PC reciprocal estimates retain native refinement steps, not Xenon bit parity. +use crate::player::offboard::grab_scene::{Record,closest_point}; +type Vector = [f32; 4]; + +fn dot(a: Vector, b: Vector) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i]-b[i]) } +fn scale(a: Vector, s: f32) -> Vector { a.map(|x| x*s) } +fn inverse_length(squared: f32) -> f32 { + let mut r = crate::physics::reciprocal_sqrt::estimate(squared); + for _ in 0..2 { r = (r*0.5).mul_add((-squared).mul_add(r*r, 1.), r); } + r +} +fn horizontal(mut v: Vector) -> Vector { + v[1] = 0.; + let squared = dot(v,v); + let r = inverse_length(squared); + let length = if squared == 0. { 0. } else { squared*r }; + //82F826F8 initializes830BD350 from82181A88, a LENGTH threshold. + if length > f32::from_bits(0x358637bd) { scale(v,r) } else { [0.;4] } +} + +///Select the nearest record FIRST, then return that record's eligibility. +///An ineligible nearer record suppresses a farther eligible record. +pub fn best_spline(records: &[Record], position: Vector) -> Option { + let mut distance = f32::from_bits(0x47c34ff3); //822F8FF0. + let mut best = None; + for record in records { + let endpoints = record.endpoints(); + let delta = sub(closest_point(position,endpoints),position); + let direction = horizontal(sub(endpoints[0],endpoints[1])); + let approach = horizontal(delta); + let eligible = delta[1] > f32::from_bits(0xbf19999a) + && delta[1] < f32::from_bits(0x3f19999a) + && dot(direction,approach).abs() < f32::from_bits(0x3f4ccccd); + let candidate_distance = dot(delta,delta); + //82D4D39C is bge: unordered does not take the skip branch. + if !(candidate_distance >= distance) { + distance = candidate_distance; + best = eligible.then(|| record.clone()); + } + } + best +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/qualify/tests.rs b/crates/skate-core/src/player/offboard/grab_scene/qualify/tests.rs new file mode 100644 index 00000000..ef93f3c4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/qualify/tests.rs @@ -0,0 +1,42 @@ +use super::*; +use std::sync::Arc; +use crate::player::offboard::grab_scene::Geometry; +fn spline(points:Vec)->Record { + let mut r=Record([0;72],Arc::new(Geometry {id:1,points, + approach_vectors:vec![[0.,0.,1.,0.]],word_60:0})); + for (i,axis) in [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.;4]].into_iter().enumerate() { + r.set_vector(i*16,axis); + } + let a=r.points()[0];let b=*r.points().last().unwrap(); + r.set_vector(64,a);r.set_vector(80,b);r.set_vector(96,[0.,0.,1.,0.]); + r.0[49]=1; + r.0[44]=r.points().windows(2).map(|p|length(sub(p[1],p[0]))).sum::().to_bits(); + r +} +#[test] +fn evaluates_full_polyline_not_endpoint_chord() { + let r=spline(vec![[0.;4],[1.,0.,0.,0.],[1.,0.,1.,0.]]); + let point=polyline::at_distance(&r,1.5); + assert!((point[0]-1.).abs()<1e-6 && (point[2]-0.5).abs()<1e-6); + let along=polyline::nearest_distance(&r,[1.,0.,0.5,0.]); + assert!((along-1.5).abs()<1e-6); +} +#[test] +fn reverse_flag_changes_arc_length_traversal() { + let mut r=spline(vec![[0.;4],[1.,0.,0.,0.],[1.,0.,1.,0.]]); + r.0[50]|=0x20000000; + let point=polyline::at_distance(&r,0.5); + assert!((point[0]-1.).abs()<1e-6 && (point[2]-0.5).abs()<1e-6); +} +#[test] +fn qualification_requires_real_approach_facing_and_box() { + let r=spline(vec![[-1.,0.,0.,0.],[1.,0.,0.,0.]]); + let bounds=Bounds {frame:[[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,-1.,0.],[0.;4]], + extents:[2.,1.,2.,0.]}; + let limits=BoardLimits {margin:0.1,angle_a:1.,angle_b:1.}; + assert!(qualify(&r,[0.,0.,1.,0.],bounds,limits)); + let mut away=bounds;away.frame[2]=[0.,0.,1.,0.]; + assert!(!qualify(&r,[0.,0.,1.,0.],away,limits)); + let mut remote=bounds;remote.frame[3]=[5.,0.,0.,0.]; + assert!(!qualify(&r,[0.,0.,1.,0.],remote,limits)); +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/query.rs b/crates/skate-core/src/player/offboard/grab_scene/query.rs new file mode 100644 index 00000000..26740313 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/query.rs @@ -0,0 +1,48 @@ +use super::{math::*,Provider,Query,Record,Registry,qualify,closest_point}; +///82760508 mode4, actual provider enumeration, cap32 BEFORE distance sort. +pub fn query(registry:&Registry,q:&Query)->Result,&'static str> { + if q.mode!=4 {return Err("This offboard provider implements native bounded mode4 only");} + if q.capacity>32 {return Err("Native grab query buffer capacity is32");} + if q.position.iter().chain(q.sort_position.iter()).chain(q.bounds.extents.iter()) + .chain(q.bounds.frame.iter().flatten()).any(|v|!v.is_finite()) { + return Err("Nonfinite grab query"); + } + let radius=length(q.bounds.extents);let center=q.bounds.frame[3]; + let mut out=Vec::with_capacity(32); + for object in ®istry.objects { + if object.disabled || !object.assembly_ready || object.assembly.is_none() {continue;} + match object.provider { + Provider::Dmo {selection_variant,matching_group,record_enabled}=> { + if !record_enabled || u32::from(selection_variant)!=(q.context.selection_flags_2948>>1)&1 + || !(q.context.matching_id_2952==-1 || matching_group==-1 + || matching_group==q.context.matching_id_2952) {continue;} + } + Provider::LivingWorld=> { + let d=sub(object.frame[3],center);let limit=radius+15.0; + if dot(d,d)>=limit*limit {continue;} + } + } + for spline in &object.splines { + if spline.descriptor.kind!=2 || spline.geometry.points.len()<2 + || spline.geometry.approach_vectors.is_empty() {continue;} + let intersects=spline.geometry.points.windows(2).any(|pair| { + let delta=sub(pair[1],pair[0]); + dot(delta,delta)>f32::from_bits(0x3727c5ac) + && sphere_segment(center,radius,point(object.frame,pair[0]),point(object.frame,pair[1])) + }); + if !intersects || out.len()==32 {continue;} + let record=object.record(spline)?; + if qualify(&record,q.position,q.bounds,q.limits) {out.push(record);} + } + } + //82761C58 stores squared closest-segment distance at184. Since count<=32, + //82762178 always dispatches stable insertion sort827629D8 (strict shifts). + for r in &mut out { + let delta=sub(q.sort_position,closest_point(q.sort_position,r.endpoints())); + r.0[46]=dot(delta,delta).to_bits(); + } + for i in 1..out.len() {let mut j=i;while j>0 && out[j].scalar(184), + pub approach_vectors:Vec, + pub word_60:u32, +} + +///Actual first-part data. None is native assembly with no first part; the +///native default coefficients apply only to that proven branch. +#[derive(Clone,Debug)] +pub struct AssemblyData { + pub identity:u32, + pub first_part:Option, +} +#[derive(Clone,Debug)] +pub struct PartData { + pub identity:u32, + pub rates:Option, + pub coefficients_0_to_36:[f32;10], +} +#[derive(Clone,Debug)] +pub struct RatesData { + pub identity:u32, + pub vector_48:Vector, + pub transform_position_48:Vector, +} +pub struct RecordInput<'a> { + pub descriptor:Descriptor, + pub geometry:Arc, + pub frame:Frame, + pub object_vector_128:Vector, + pub assembly:Option<&'a AssemblyData>, + pub word_272:u32, +} +impl Record { + pub fn from_geometry(i:RecordInput<'_>)->Result { + if i.geometry.id==0 || i.geometry.points.is_empty() || i.geometry.points.len()>255 + || i.geometry.approach_vectors.len()>255 { + return Err("Grab geometry needs a real identity and native byte-sized point counts"); + } + if i.geometry.points.iter().chain(&i.geometry.approach_vectors).flatten() + .chain(i.frame.iter().flatten()).chain(i.object_vector_128.iter()).any(|v|!v.is_finite()) { + return Err("Nonfinite authored grab geometry/frame"); + } + let mut r=Self([0;72],i.geometry); + for j in 0..4 {r.set_vector(j*16,i.frame[j]);} + r.0[47]=i.descriptor.kind;r.0[48]=i.descriptor.id;r.0[49]=r.1.id; + r.0[50]=0x80000000;r.0[51]=r.1.word_60;r.0[68]=i.word_272; + let start=point(i.frame,r.1.points[0]); + let end=point(i.frame,*r.1.points.last().unwrap()); + let approach=r.1.approach_vectors.first().copied().unwrap_or([0.,1.,0.,0.]); + r.set_vector(64,start);r.set_vector(80,end); + r.set_vector(96,direction(i.frame,approach));r.set_vector(128,i.object_vector_128); + r.set_vector(224,[1.;4]); + for (offset,value) in [(240,1.),(244,1.),(248,f32::MAX),(252,f32::MAX)] {r.0[offset/4]=value.to_bits();} + if let Some(a)=i.assembly { + if a.identity==0 {return Err("Zero assembly identity is reserved for native null");} + r.0[52]=a.identity; + if let Some(p)=&a.first_part { + if p.identity==0 {return Err("Zero part identity is reserved for native null");} + r.0[53]=p.identity; + for j in 0..10 {r.0[56+j]=p.coefficients_0_to_36[j].to_bits();} + if let Some(rates)=&p.rates { + if rates.identity==0 {return Err("Zero rates identity is reserved for native null");} + r.0[54]=rates.identity;r.0[50]|=0x40000000; + r.set_vector(144,rates.vector_48);r.set_vector(160,rates.transform_position_48); + } + } + } + let delta=sub(end,start);let straight=length(delta);let inv=reciprocal(straight); + r.set_vector(112,delta.map(|v|v*inv));r.0[45]=(straight*0.5).to_bits(); + let mut total=0.0; + for pair in r.1.points.windows(2) {total+=length(sub(pair[1],pair[0]));} + r.0[44]=total.to_bits(); + Ok(r) + } + pub fn set_vector(&mut self,offset:usize,value:Vector) { + self.0[offset/4..offset/4+4].copy_from_slice(&value.map(f32::to_bits)); + } +} diff --git a/crates/skate-core/src/player/offboard/grab_scene/registry.rs b/crates/skate-core/src/player/offboard/grab_scene/registry.rs new file mode 100644 index 00000000..68136173 --- /dev/null +++ b/crates/skate-core/src/player/offboard/grab_scene/registry.rs @@ -0,0 +1,59 @@ +//! Explicit authored objects and physical identities. Query meshes do not +//! imply interactability, spline descriptors, or assembly identities. +use super::{Descriptor,Frame,Geometry,Hit,Record,RecordInput,Vector,AssemblyData}; +use std::sync::Arc; + +#[derive(Clone,Copy,Debug)] +pub enum Provider { + ///82589130: selected record44/48, flag52 and matching assembly40. + Dmo { selection_variant:u8,matching_group:i32,record_enabled:bool }, + ///82C4BE80: actual dynamic-object provider, no actor matching gate. + LivingWorld, +} +#[derive(Clone,Debug)] +pub struct Spline { pub descriptor:Descriptor,pub geometry:Arc,pub word_272:u32 } +#[derive(Clone,Debug)] +pub struct Object { + pub id:u32, + pub provider:Provider, + ///Real provider exclusion: Dmo record92 / LW virtual108. + pub disabled:bool, + pub assembly_ready:bool, + pub assembly:Option, + pub frame:Frame, + pub object_vector_128:Vector, + pub splines:Vec, +} +impl Object { + pub fn record(&self,spline:&Spline)->Result { + Record::from_geometry(RecordInput {descriptor:spline.descriptor,geometry:spline.geometry.clone(), + frame:self.frame,object_vector_128:self.object_vector_128,assembly:self.assembly.as_ref(),word_272:spline.word_272}) + } +} +#[derive(Clone,Debug)] +pub struct Registry {pub objects:Vec} +impl Registry { + pub fn new(objects:Vec)->Result { + for (index,object) in objects.iter().enumerate() { + if object.id==0 {return Err("Interactable object ID zero is reserved for none");} + if let Provider::Dmo {selection_variant,..}=object.provider { + if selection_variant>1 {return Err("Dmo body selection is a single bit");} + } + for spline in &object.splines { + if !matches!(spline.descriptor.kind,1|2) {return Err("Unknown native grab descriptor kind");} + object.record(spline)?; + } + if objects[..index].iter().any(|prior|prior.id==object.id) { + return Err("Duplicate authored interactable object ID"); + } + } + Ok(Self {objects}) + } + pub fn eligible_object(&self,hit:Hit)->Option { + //82760368 rejects zero distance and missing assembly BEFORE lookup. + if hit.fraction==0.0 {return None;} + let assembly=hit.assembly?; + self.objects.iter().find(|object|!object.disabled && object.assembly.as_ref() + .is_some_and(|a|a.identity==assembly)).map(|object|object.id) + } +} diff --git a/crates/skate-core/src/player/offboard/ground_job.rs b/crates/skate-core/src/player/offboard/ground_job.rs index 9edcd08b..9757bccc 100644 --- a/crates/skate-core/src/player/offboard/ground_job.rs +++ b/crates/skate-core/src/player/offboard/ground_job.rs @@ -1,135 +1,200 @@ -//! BipedGround PreUpdate82D30D30 job production. The scene/host completes -//! contact queries before this call and retains the resulting contact packet. -use super::{contact_packet::Packet,controller::{GroundJob,Frame,Vector}, - ground_entry::State,ground_input::{self,GroundInput},ground_query}; -use crate::{math::Vector3,point_graph::PointGraph}; +//! Ground-state PreUpdate82D30D30, prefix copy82D31040 and geometry82D31620. +//! Original Skate3 SHA256: +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Skate2 locomotion82DA0678 uses FeetIK/Skeleton, not this Biped job contract. +use super::{ + contact_toolkit::ContactPrefix, + controller::{Frame, GroundJob, Vector}, + ground_input::GroundInputOutput, + ground_query::{ConsumeInput, GroundAdjustment}, +}; +use crate::math::Vector3; +pub const STEP: f32 = f32::from_bits(0x3c88_8889); + +/// The shared toolkit owner publishes BOTH fields, including its reset prefix +/// when refresh completes without a submitted batch. Absence is not readiness. +#[derive(Clone, Copy, Debug)] +pub struct ContactSnapshot { + pub readiness: i32, + pub prefix: ContactPrefix, +} + +#[derive(Clone, Copy, Debug)] pub struct Input { + pub contact: ContactSnapshot, + pub frame: Frame, pub previous_state: u32, + /// Only processed third-line1056 with its actual valid byte1092 nonzero. + pub third_line_position: Option, pub frames_since_teleport: u32, - pub fallback_line_hit: Option, - pub controls: GroundInput, + pub processed_position: Vector, + pub processed_velocity: Vector, + pub controls: GroundInputOutput, + pub collision_displacements: [Vector; 2], + pub animation_motion: Vector, + pub animation_velocity: Vector, + pub requested_duration: f32, + pub requested_phase: f32, + pub override_duration: f32, + pub flags_2472: u32, pub flags_2476: u32, pub flags_2480: u32, pub flags_2484: u32, pub flags_2488: u32, - pub animation_motion_2864: Vector, - pub duration_2896: f32, - pub phase_2900: f32, - pub override_duration_2904: f32, - pub position_592: Vector, - pub velocity_608: Vector, - pub skeleton_motion_16320: Vector, - pub skeleton_displacements_16288_16304: [Vector;2], } + pub struct Prepared { pub job: GroundJob, - /// Retained until FillPhysOut82D32D38, including754 which is not a controller input. - pub geometry_flags_752_to_754: [bool;3], -} -fn xyz(v: Vector) -> Vector3 {Vector3::new(v[0],v[1],v[2])} -fn vector(v: Vector3) -> Vector {[v.x,v.y,v.z,0.]} -fn frame(f: Frame) -> ground_query::Frame { - ground_query::Frame {right:xyz(f[0]),up:xyz(f[1]),forward:xyz(f[2]),position:xyz(f[3])} + /// Includes state754, retained for the physical state's later consumers even + /// though the existing controller does not read that byte. + pub geometry: GroundAdjustment, } -/// A completed toolkit packet exists only when the native contact age320>0. -/// Missing completion preserves retained contact fields, except for the original -/// preceding-riding-state line fallback. No synthetic floor hit is introduced. -pub fn prepare(state: &mut State,retained: &mut Packet,completed: Option, - geometry: Option,input: &Input, - movement_curve: &PointGraph<8>,turn_curve: &PointGraph<8>) -> Prepared -{ - let timestep=f32::from_bits(0x3c88_8889); - state.distance_164+=timestep; - let mut both_feet=true; - if let Some(packet)=completed { - *retained=packet; - both_feet=retained.flags & 0x30 == 0x30; - } else if input.previous_state!=501 { - if let Some(position)=input.fallback_line_hit { - retained.flags=1; - retained.position=position; - retained.normal=state.frame_80[1]; +/// Retained contact192 and timer164 belong to the EXISTING Ground state. +/// The callback consumes the preceding geometry submission exactly once, after +/// contact selection and before controller execution. Errors are never support. +pub fn prepare( + retained: &mut ContactPrefix, + timer_164: &mut f32, + input: Input, + consume_geometry: impl FnOnce(ConsumeInput) -> Result, +) -> Result { + *timer_164 += STEP; + // r27 starts at1 (82D30D54); only the ready branch recomputes both-feet. + let mut both_feet = true; + if input.contact.readiness > 0 { + *retained = input.contact.prefix; + both_feet = retained.flags_176 & 0x30 == 0x30; + } else if input.previous_state != 501 { + if let Some(position) = input.third_line_position { + //82D30DC8..DE4 changes ONLY these three fields. + retained.flags_176 = 1; + retained.position = position; + retained.normal = input.frame[1]; } } - let suppress_minimum=input.frames_since_teleport<20; - if suppress_minimum {retained.flags &= !8;} - let mut controls=input.controls; - controls.frame_forward_112=[state.frame_80[2][0],state.frame_80[2][1],state.frame_80[2][2]]; - let control=ground_input::calculate(&controls,movement_curve,turn_curve); - let adjustment=ground_query::consume_geometry(ground_query::ConsumeInput { - frame_80:frame(state.frame_80),contact_position_192:xyz(retained.position), - contact_flags_368:retained.flags,reach_364:retained.distance_172, - previous_input_up_416:xyz(retained.normal), - },geometry); - if !both_feet && !adjustment.state_753 && retained.flags & 8 == 0 {state.distance_164=0.;} - let target=adjustment.frame_768; - let job=GroundJob { - contact_position:retained.position, - contact_normal:[adjustment.input_up_416.x,adjustment.input_up_416.y,adjustment.input_up_416.z,retained.normal[3]], - support_frame:retained.support_frame,target_position:retained.target_position, - target_normal:retained.target_normal,edge_position:retained.edge_position,edge_normal:retained.edge_normal, - flags:retained.flags,support_id:retained.support_id, - collision_displacements:input.skeleton_displacements_16288_16304, - animation_motion:input.animation_motion_2864,animation_velocity:input.skeleton_motion_16320, - desired_direction:control.state_656, - animation_position:std::array::from_fn(|i|input.velocity_608[i].mul_add(timestep,input.position_592[i])), - requested_duration:input.duration_2896,mirrored:input.flags_2476 & 4 != 0, - requested_phase:input.phase_2900,override_duration:input.override_duration_2904, - animation_directed:input.flags_2488 & 0x0800_0000 != 0, - movement:control.state_708,steering:control.state_712,sprint_pressed:input.flags_2484 & 0x20000 != 0, - suppress_lean:input.flags_2480 & 128 != 0,suppress_minimum, - target_frame_present:adjustment.state_752,edge_active:adjustment.state_753, - target_frame:[vector(target.right),vector(target.up),vector(target.forward),vector(target.position)], - ignore_obstacle:input.controls.processed_flags_2472 & 0x1000_0000 != 0, + //82D30DF0..E04: carry/sign sequence is signed <20, including high-bit words. + let suppress_minimum = (input.frames_since_teleport as i32) < 20; + if suppress_minimum { + retained.flags_176 &= !8; + } + //82D30EA4 copies before geometry changes ONLY the job's normal at416. + let copied = *retained; + let geometry = consume_geometry(ConsumeInput { + frame_80: query_frame(input.frame), + contact_position_192: xyz(retained.position), + contact_flags_368: retained.flags_176, + reach_364: retained.distance_172, + previous_input_up_416: xyz(copied.normal), + })?; + if !both_feet && !geometry.state_753 && retained.flags_176 & 8 == 0 { + *timer_164 = 0.; + } + // VMX82D30F48: position592 + velocity608 * float1/60, not render/solver dt. + let animation_position = std::array::from_fn(|i| { + input.processed_velocity[i].mul_add(STEP, input.processed_position[i]) + }); + let job = GroundJob { + contact_position: copied.position, + contact_normal: [geometry.input_up_416.x, geometry.input_up_416.y, + geometry.input_up_416.z, copied.normal[3]], + support_frame: copied.support_frame, + target_position: copied.target_position, + target_normal: copied.target_normal, + edge_position: copied.edge_position, + edge_normal: copied.edge_normal, + flags: copied.flags_176, + support_id: copied.support_180, + collision_displacements: input.collision_displacements, + animation_motion: input.animation_motion, + animation_velocity: input.animation_velocity, + desired_direction: input.controls.state_656, + animation_position, + requested_duration: input.requested_duration, + mirrored: input.flags_2476 & 4 != 0, + requested_phase: input.requested_phase, + override_duration: input.override_duration, + animation_directed: input.flags_2488 & 0x0800_0000 != 0, + movement: input.controls.state_708, + steering: input.controls.state_712, + sprint_pressed: input.flags_2484 & 0x0002_0000 != 0, + suppress_lean: input.flags_2480 & 0x80 != 0, + suppress_minimum, + target_frame_present: geometry.state_752, + edge_active: geometry.state_753, + target_frame: native_frame(geometry.frame_768), + ignore_obstacle: input.flags_2472 & 0x1000_0000 != 0, }; - Prepared {job,geometry_flags_752_to_754:[adjustment.state_752,adjustment.state_753,adjustment.state_754]} + Ok(Prepared { job, geometry }) } -#[cfg(test)] -mod tests { - use super::*; - fn input() -> Input { - Input {previous_state:500,frames_since_teleport:20,fallback_line_hit:None, - controls:GroundInput {processed_flags_2472:0x1000_0000,processed_direct_2684:0.75, - processed_direct_2680:-0.5,processed_stick_2692:0.,processed_stick_2688:0., - processed_scale_2912:1.,processed_scale_2908:1.,frame_forward_112:[0.,0.,1.]}, - flags_2476:4,flags_2480:128,flags_2484:0x20000,flags_2488:0x0800_0000, - animation_motion_2864:[1.,2.,3.,4.],duration_2896:0.25,phase_2900:0.75,override_duration_2904:1.5, - position_592:[10.,20.,30.,0.],velocity_608:[60.,120.,180.,0.],skeleton_motion_16320:[4.,3.,2.,1.], - skeleton_displacements_16288_16304:[[1.;4],[2.;4]]} - } - fn curves() -> PointGraph<8> {PointGraph {x:std::array::from_fn(|i|i as f32),y:[1.;8]}} - #[test] - fn delayed_contact_has_priority_and_suppression_expires_at_twenty() { - let curves=curves(); - for age in [19,20] { - let mut state=State::default(); - let mut retained=Packet::default(); - let mut input=input(); input.frames_since_teleport=age;input.fallback_line_hit=Some([99.;4]); - let mut completed=Packet::default();completed.flags=57;completed.position=[0.,-1.,0.,0.]; - let prepared=prepare(&mut state,&mut retained,Some(completed),None,&input,&curves,&curves); - let job=prepared.job; - assert_eq!(job.contact_position,completed.position); - assert_eq!(job.suppress_minimum,age==19); - assert_eq!(job.flags,if age==19 {49} else {57}); - assert_eq!(job.animation_position,[11.,22.,33.,0.]); - assert_eq!(job.collision_displacements,[[1.;4],[2.;4]]); - assert_eq!(job.movement,0.75); assert_eq!(job.steering,-0.5); - assert!(job.mirrored && job.sprint_pressed && job.suppress_lean && job.animation_directed && job.ignore_obstacle); +/// Frame-producing portion of Sync82D31E64..82D32070. The same Ground state is +/// updated in place; callers use this frame for Skeleton AND toolkit inputs. +/// Air-launch preparation and board-manager actions retain their shared owners. +pub fn sync_frames( + state: &mut super::ground_entry::State, + contact: ContactPrefix, + result: super::controller::GroundResult, +) -> Frame { + state.flags_144_to_150[3] = result.sliding; + state.frame_80 = result.physical_frame; + if contact.flags_176 & 1 != 0 { + let delta = std::array::from_fn(|i| contact.position[i] - state.frame_80[3][i]); + let height = crate::physics::native_arithmetic::dot3(delta, state.frame_80[1]); + //82D31ECC ble skips the correction; unordered follows the arithmetic. + if !(height <= 0.) { + state.frame_80[3] = madd(state.frame_80[1], height, state.frame_80[3]); } } - #[test] - fn fallback_is_only_used_when_no_completed_contact_and_previous_state_is_not_biped_air() { - let curves=curves(); - for previous in [500,501] { - let mut state=State::default(); - let mut retained=Packet::default(); - let mut input=input();input.previous_state=previous;input.fallback_line_hit=Some([0.,-1.,0.,0.]); - let result=prepare(&mut state,&mut retained,None,None,&input,&curves,&curves); - assert_eq!(result.job.flags,if previous==501 {0} else {1}); - assert_eq!(retained.position,if previous==501 {[0.;4]} else {[0.,-1.,0.,0.]}); + let mut animation = result.animation_frame; + if state.flags_144_to_150[6] { + state.duration_180 -= STEP; + if !(state.duration_180 > 0.) { + state.duration_180 = 0.; + state.flags_144_to_150[6] = false; } + state.angle_172 *= f32::from_bits(0x3f66_6666); + let angle = (-state.angular_velocity_176).mul_add(state.duration_180, state.angle_172); + let (sin, cos) = crate::trigonometry::sin_cos(angle); + // Geometric host frame: do not publish Rodrigues permutation scratch W. + let forward = madd([sin, 0., cos, 0.], animation[2][2], + madd([0., 1., 0., 0.], animation[2][1], + [cos, 0., -sin, 0.].map(|v| v * animation[2][0]))); + // Original has no zero-length replacement on these two normalizations. + let right = normalize(cross(animation[1], forward)); + animation[0] = right; + animation[1] = normalize(cross(forward, right)); + animation[2] = forward; + } + animation +} + +fn madd(v: Vector, scale: f32, offset: Vector) -> Vector { + std::array::from_fn(|i| v[i].mul_add(scale, offset[i])) +} +fn cross(a: Vector, b: Vector) -> Vector { + [(-a[2]).mul_add(b[1], a[1]*b[2]), (-a[0]).mul_add(b[2], a[2]*b[0]), + (-a[1]).mul_add(b[0], a[0]*b[1]), (-a[3]).mul_add(b[3], a[3]*b[3])] +} +fn normalize(v: Vector) -> Vector { + let square = crate::physics::native_arithmetic::dot3(v, v); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse*0.5).mul_add((-square).mul_add(inverse*inverse, 1.), inverse); + } + v.map(|lane| lane*inverse) +} + +fn xyz(v: Vector) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +fn query_frame(f: Frame) -> super::ground_query::Frame { + super::ground_query::Frame { + right: xyz(f[0]), up: xyz(f[1]), forward: xyz(f[2]), position: xyz(f[3]), } } +fn native_frame(f: super::ground_query::Frame) -> Frame { + [f.right, f.up, f.forward, f.position].map(|v| [v.x, v.y, v.z, 0.]) +} +#[cfg(test)] +#[path = "ground_job_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_job_tests.rs b/crates/skate-core/src/player/offboard/ground_job_tests.rs new file mode 100644 index 00000000..6f4e6e33 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_job_tests.rs @@ -0,0 +1,191 @@ +use super::*; +use crate::player::offboard::{contact_toolkit::IDENTITY, ground_query}; + +fn input() -> Input { + Input { + contact: ContactSnapshot { readiness: 30, prefix: ContactPrefix::reset() }, + frame: IDENTITY, + previous_state: 500, + third_line_position: None, + frames_since_teleport: 20, + processed_position: [2.; 4], + processed_velocity: [60.; 4], + controls: GroundInputOutput { state_708: 0.7, state_712: -0.2, state_656: [1., 0., 2., 0.] }, + collision_displacements: [[3.; 4], [4.; 4]], + animation_motion: [5.; 4], + animation_velocity: [6.; 4], + requested_duration: 0.8, + requested_phase: 0.3, + override_duration: 0.4, + flags_2472: 0x1000_0000, + flags_2476: 4, + flags_2480: 0x80, + flags_2484: 0x20000, + flags_2488: 0x0800_0000, + } +} +fn run(contact: &mut ContactPrefix, timer: &mut f32, input: Input) -> Prepared { + prepare(contact, timer, input, |i| { + Ok::<_, ()>(ground_query::consume_geometry(i, None)) + }).unwrap() +} + +#[test] +fn ready_copies_whole_prefix_not_processed_flags_or_wheel_support() { + let mut i = input(); + i.contact.prefix = ContactPrefix { + position: [1., 2., 3., 0.], normal: [0.6, 0.8, 0., 0.], + support_frame: [[7.; 4]; 4], target_position: [8.; 4], target_normal: [9.; 4], + edge_position: [10.; 4], edge_normal: [11.; 4], scalar_160: 12., + kind_164: 13, distance_168: 14., distance_172: 15., + flags_176: 0x31, support_180: 16, + }; + let mut retained = ContactPrefix::reset(); + let mut timer = 0.; + let out = run(&mut retained, &mut timer, i); + assert_eq!(retained, i.contact.prefix); + assert_eq!(out.job.flags, 0x31); + assert_eq!(out.job.support_id, 16); + assert_eq!(out.job.contact_normal, retained.normal); + assert_eq!(out.job.support_frame, retained.support_frame); + assert_eq!(out.job.target_position, retained.target_position); + assert_eq!(out.job.target_normal, retained.target_normal); + assert_eq!(out.job.edge_position, retained.edge_position); + assert_eq!(out.job.edge_normal, retained.edge_normal); + assert_eq!(timer, STEP); +} + +#[test] +fn unready_native_third_line_changes_only_three_fields_and_keeps_timer() { + let mut retained = ContactPrefix::reset(); + retained.support_180 = 42; + retained.distance_172 = 17.; + retained.target_position = [9.; 4]; + let old = retained; + let mut i = input(); + i.contact.readiness = 0; + i.third_line_position = Some([0., -2., 0., 0.]); + let mut timer = 0.; + run(&mut retained, &mut timer, i); + assert_eq!(retained, ContactPrefix { + flags_176: 1, position: [0., -2., 0., 0.], normal: i.frame[1], ..old + }); + assert_eq!(timer, STEP); //r27 remains1 on the unready branch. +} + +#[test] +fn no_third_line_or_previous_501_does_not_invent_support() { + for previous in [500, 501] { + let mut i = input(); + i.contact.readiness = -1; + i.previous_state = previous; + i.third_line_position = if previous == 501 { Some([7.; 4]) } else { None }; + let mut retained = ContactPrefix::reset(); + let before = retained; + let mut timer = 0.; + run(&mut retained, &mut timer, i); + assert_eq!(retained, before); + assert_eq!(timer, STEP); + } +} + +#[test] +fn ready_reset_packet_is_not_treated_as_unready_or_stale_contact() { + let mut i = input(); + i.third_line_position = Some([8.; 4]); + let mut retained = ContactPrefix::reset(); + retained.flags_176 = 0x39; + retained.position = [9.; 4]; + let mut timer = 2.; + run(&mut retained, &mut timer, i); + assert_eq!(retained, ContactPrefix::reset()); + assert_eq!(timer, 0.); +} + +#[test] +fn teleport_signed_guard_and_fixed_step_prediction_match_preupdate() { + for (frames, suppressed) in [(19, true), (20, false), (u32::MAX, true)] { + let mut i = input(); + i.frames_since_teleport = frames; + i.contact.prefix.flags_176 = 0x38; + let mut retained = ContactPrefix::reset(); + let out = run(&mut retained, &mut 0., i); + assert_eq!(out.job.suppress_minimum, suppressed); + assert_eq!(retained.flags_176 & 8 == 0, suppressed); + let expected = 60_f32.mul_add(f32::from_bits(0x3c88_8889), 2.); + assert_eq!(out.job.animation_position, [expected; 4]); + assert_eq!(out.job.collision_displacements, i.collision_displacements); + assert_eq!(out.job.requested_phase, 0.3); + assert_eq!(out.job.override_duration, 0.4); + assert_eq!(out.job.desired_direction, i.controls.state_656); + assert!(out.job.mirrored && out.job.sprint_pressed && out.job.suppress_lean); + assert!(out.job.animation_directed && out.job.ignore_obstacle); + } +} + +#[test] +fn geometry_changes_job_normal_without_overwriting_retained_prefix() { + let mut i = input(); + i.contact.prefix.normal = [0.6, 0.8, 0., 0.]; + i.contact.prefix.flags_176 = 0x30; + let mut retained = ContactPrefix::reset(); + let mut count = 0; + let out = prepare(&mut retained, &mut 0., i, |input| { + count += 1; + assert_eq!(input.reach_364, 1.0e10); + Ok::<_, ()>(ground_query::consume_geometry(input, Some(ground_query::GroundGeometry { + frame: ground_query::Frame::IDENTITY, + kind: 2, flag26: true, flag27: false, flag28: false, + }))) + }).unwrap(); + assert_eq!(count, 1); + assert_eq!(retained.normal, [0.6, 0.8, 0., 0.]); + assert_eq!(out.job.contact_normal, [0., 1., 0., 0.]); + assert!(out.geometry.state_754); + assert!(out.job.target_frame_present); + assert!(!out.job.edge_active); +} + +#[test] +fn geometry_failure_is_not_converted_to_a_ground_job() { + assert!(prepare(&mut ContactPrefix::reset(), &mut 0., input(), |_| { + Err::("real query failed") + }).is_err()); +} + +fn motion() -> super::super::controller::GroundResult { + super::super::controller::GroundResult { + physical_frame: IDENTITY, animation_frame: IDENTITY, surface_frame: IDENTITY, + velocity: [0.; 4], position: [0.; 4], angular_velocity: 0., + alternate: false, sliding: false, + } +} + +#[test] +fn sync_corrects_only_positive_contact_height_and_preserves_result() { + for (height, expected) in [(0.2, 0.2), (-0.2, 0.)] { + let mut state = super::super::ground_entry::State::default(); + let mut contact = ContactPrefix::reset(); + contact.flags_176 = 1; + contact.position[1] = height; + let result = motion(); + assert_eq!(sync_frames(&mut state, contact, result), IDENTITY); + assert_eq!(state.frame_80[3][1], expected); + assert_eq!(result.physical_frame, IDENTITY); + } +} + +#[test] +fn sync_finishes_enter_angle_unwind_before_publishing_animation_axes() { + let mut state = super::super::ground_entry::State::default(); + state.flags_144_to_150[6] = true; + state.duration_180 = STEP; + state.angle_172 = 1.; + state.angular_velocity_176 = 3.; + let frame = sync_frames(&mut state, ContactPrefix::reset(), motion()); + assert!(!state.flags_144_to_150[6]); + assert_eq!(state.duration_180, 0.); + let (sin, cos) = crate::trigonometry::sin_cos(0.9); + assert!((frame[2][0] - sin).abs() < 1.0e-6); + assert!((frame[2][2] - cos).abs() < 1.0e-6); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/math.rs b/crates/skate-core/src/player/offboard/ground_motion/math.rs index 45417ef4..bb7d2694 100644 --- a/crates/skate-core/src/player/offboard/ground_motion/math.rs +++ b/crates/skate-core/src/player/offboard/ground_motion/math.rs @@ -88,7 +88,8 @@ pub(super) fn inverse_rigid(f: Frame) -> Frame { crate::physics::skeleton_root::inverse_rigid(&f) } -///82D7FEE0..FFCC Rodrigues columns, including original repeated X in W. +///82D7FEE0..FFCC XYZ Rodrigues rotation, using the host's geometric W=0 +///representation rather than native permutation scratch lanes. pub(super) fn rotate(v: Vector, axis: Vector, angle: f32) -> Vector { let [x, y, z, _] = axis; let (s, c) = trigonometry::sin_cos(angle); @@ -99,9 +100,9 @@ pub(super) fn rotate(v: Vector, axis: Vector, angle: f32) -> Vector { let yx = ty * x - sz; let zx = tz.mul_add(x, sy); let columns = [ - [xx, tx.mul_add(y, sz), tx * z - sy, xx], - [yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx], - [zx, tz * y - sx, tz.mul_add(z, c), zx], + [xx, tx.mul_add(y, sz), tx * z - sy, 0.], + [yx, ty.mul_add(y, c), ty.mul_add(z, sx), 0.], + [zx, tz * y - sx, tz.mul_add(z, c), 0.], ]; transform_vector(v, [columns[0], columns[1], columns[2], ZERO]) } diff --git a/crates/skate-core/src/player/offboard/hippy_jump.rs b/crates/skate-core/src/player/offboard/hippy_jump.rs new file mode 100644 index 00000000..dc4e30ac --- /dev/null +++ b/crates/skate-core/src/player/offboard/hippy_jump.rs @@ -0,0 +1,57 @@ +//! TU3 `Skateboard::Toolkit_CalcHippyJumpVel` at `0x82D79F00`. +//! +//! The helper constructs the rider trajectory velocity used by +//! `PhysState_LandingOnDeck::InitTraj`; it does not write board-body velocity. + +pub type Vector = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub desired_height: f32, + pub board_position: Vector, + pub centre_of_mass_position: Vector, + pub reference_up: Vector, + pub current_velocity: Vector, +} + +pub fn calculate(input: Input) -> Vector { + let up = input.reference_up; + let displacement = sub(input.centre_of_mass_position, input.board_position); + let current_height = dot3(up, displacement); + let vertical_velocity = dot3(up, input.current_velocity); + let planar_velocity = sub(input.current_velocity, scale(up, vertical_velocity)); + let launch_speed = ((input.desired_height - current_height) * 19.6).sqrt(); + add(planar_velocity, scale(up, launch_speed)) +} + +fn dot3(a: Vector, b: Vector) -> f32 { + a[0].mul_add(b[0], a[1].mul_add(b[1], a[2] * b[2])) +} +fn add(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] + b[i]) +} +fn sub(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] - b[i]) +} +fn scale(v: Vector, scalar: f32) -> Vector { + core::array::from_fn(|i| v[i] * scalar) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_planar_velocity_and_replaces_up_component() { + let output = calculate(Input { + desired_height: 1.5, + board_position: [0.0, 0.0, 0.0, 0.0], + centre_of_mass_position: [0.0, 0.5, 0.0, 0.0], + reference_up: [0.0, 1.0, 0.0, 0.0], + current_velocity: [3.0, -2.0, 4.0, 0.0], + }); + assert_eq!(output[0], 3.0); + assert_eq!(output[2], 4.0); + assert!((output[1] - 19.6f32.sqrt()).abs() < 1.0e-6); + } +} diff --git a/crates/skate-core/src/player/offboard/landing_deck.rs b/crates/skate-core/src/player/offboard/landing_deck.rs index 3fe6aee7..eb0e051e 100644 --- a/crates/skate-core/src/player/offboard/landing_deck.rs +++ b/crates/skate-core/src/player/offboard/landing_deck.rs @@ -1,74 +1,119 @@ -//! LandingOnDeckManager trajectory preparation,82D78B28/82D78EE8/82D78D30. -//! The board frame and velocity are observations of the canonical live board. -use super::air_launch::{V,dot,length,madd,scale,sub}; -use crate::air::trajectory::Trajectory; -const ZERO:V=[0.;4]; -const GRAVITY:V=[0.,-9.8,0.,0.]; -#[derive(Clone,Copy,Debug)] -pub struct Settings {pub deck_min_uprightness:f32,pub approximate_com_height:f32} -#[derive(Clone,Copy,Debug)] -pub struct Input { - pub board_position:V,pub board_velocity:V,pub board_up:V,pub up:V, - pub board_contact_count:i32,pub flags_2480:u32, +//! Shared LandingOnDeckManager68 for BipedAir501 and LandingOnDeck503. +//! Original S3 TU3 SHA256: +//! 431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Host submits returned queries and acknowledges success; no world/solver owner. +//! PC arithmetic retains recovered formulas/refinements, not Xenon bit parity. +mod query; +mod trajectory; +mod types; +mod update; +pub use crate::air::trajectory::{QueryRequest, QueryResult, Trajectory}; +use crate::player::wipeout_state::math::{add, sub}; +use trajectory::{adjust, frames}; +pub use types::*; + +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +pub const STEP: f32 = f32::from_bits(0x3c88_8889); +pub const GRAVITY: Vector = [0., f32::from_bits(0xc11c_cccd), 0., 0.]; +const EMPTY: Trajectory = Trajectory { + position: [0.; 4], + velocity: [0.; 4], + acceleration: [0.; 4], + duration: -1., +}; + +///One retained owner. Public fields permit the source-backed503 InitTraj producer +///to initialize this SAME trajectory and force flag, not a parallel state object. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Manager { + pub trajectory_32: Trajectory, + pub proposed_96: Trajectory, + pub elapsed_160: f32, + pub trajectory_valid_164: bool, + pub ik_offset_176: Vector, + pub vector_192: Vector, + pub moving_contact_208: Vector, + pub vector_224: Vector, + pub obstruction_height_240: f32, + pub time_to_land_244: f32, + pub proposed_time_248: f32, + pub completed_queries_252: u32, + pub can_land_256: bool, + pub force_257: bool, + pub blocked_258: bool, + pub tested_259: bool, + pub hippy_hurdling_260: bool, + pub publish_moving_contact_261: bool, + pub pending_262: bool, } -#[derive(Clone,Debug)] -pub struct State { - pub trajectory:Trajectory,pub proposed:Trajectory,pub elapsed:f32,pub trajectory_valid:bool, - pub horizontal_correction:V,pub remaining:f32,pub proposed_remaining:f32, - pub contact_height:f32,pub query_count:i32,pub can_land:bool,pub force:bool, - pub blocked:bool,pub first_query:bool,pub hippy_hurdling:bool,pub collision_override:bool, - pub collision_normal:V,pub query_pending:bool, + +impl Default for Manager { + ///Host allocation seed, NOT a substitute for503 InitTraj or Air assistance. + fn default() -> Self { + Self { + trajectory_32: EMPTY, + proposed_96: EMPTY, + elapsed_160: 0., + trajectory_valid_164: false, + ik_offset_176: [0.; 4], + vector_192: [0.; 4], + moving_contact_208: [0.; 4], + vector_224: [0.; 4], + obstruction_height_240: 0., + time_to_land_244: 0., + proposed_time_248: 0., + completed_queries_252: 0, + can_land_256: false, + force_257: false, + blocked_258: false, + tested_259: false, + hippy_hurdling_260: false, + publish_moving_contact_261: false, + pending_262: false, + } + } } -impl Default for State { - fn default()->Self { - let trajectory=Trajectory{position:ZERO,velocity:ZERO,acceleration:ZERO,duration:-1.}; - Self{trajectory,proposed:trajectory,elapsed:0.,trajectory_valid:false,horizontal_correction:ZERO, - remaining:0.,proposed_remaining:0.,contact_height:0.,query_count:0,can_land:false,force:false, - blocked:false,first_query:false,hippy_hurdling:false,collision_override:false,collision_normal:ZERO,query_pending:false} - } -} -impl State { - ///82D78EE8: solve the descending crossing of the deck's COM-height plane, - ///then prepare the velocity matching trajectory. No scene hit is invented. - pub fn probe(&mut self,input:Input,settings:Settings,position:V,velocity:V)->bool { - let mut board_velocity=input.board_velocity; - let acceleration=if input.board_contact_count==0 {board_velocity[1]=0.;scale(GRAVITY,0.5)} else {GRAVITY}; - let relative=sub(velocity,board_velocity); - let plane=madd(input.up,settings.approximate_com_height,input.board_position); - let a=dot(scale(acceleration,0.5),input.up); - let b=dot(relative,input.up);let c=dot(sub(position,plane),input.up); - let discriminant=b*b-4.*a*c; - if discriminant<0. {self.can_land=false;return false;} - //82D60B98 picks the larger root of82D60C80; the caller requires t>0. - let root=discriminant.sqrt(); - let time=((-b+root)/(2.*a)).max((-b-root)/(2.*a)); - if !(time>0.) {self.can_land=false;return false;} - let distance=length(sub(position,plane)); - let duration=(2.*distance/9.8).sqrt().max(time*0.75).min(time*1.3); - self.proposed=Trajectory{position, - velocity:madd(input.board_velocity,1.,sub(scale(sub(plane,position),1./duration),scale(GRAVITY,0.5*duration))), - acceleration:GRAVITY,duration:-1.}; - let relative_arc=Trajectory{position,velocity:relative,acceleration,duration:-1.}; - self.horizontal_correction=sub(plane,relative_arc.position_at(time));self.horizontal_correction[1]=0.; - self.remaining=time;self.proposed_remaining=duration; - true - } - ///82D78D30, before the world's obstruction query82D79948. Some is the - ///actual duration to submit to that query; None means no submission this tick. - pub fn update_air(&mut self,input:Input,settings:Settings,position:V,velocity:V,maximum_velocity_difference:f32)->Option { - self.query_count=0;self.first_query=false; - if (!self.force&&input.flags_2480&0x8000!=0)||!(input.board_up[1]>settings.deck_min_uprightness) {return None;} - self.probe(input,settings,position,velocity); - if self.remaining>0.4 { - self.trajectory=self.proposed;self.trajectory_valid=true;self.remaining=self.proposed_remaining; - self.can_land=length(sub(velocity,self.proposed.velocity))Trajectory { - let mut target=madd(input.board_velocity,duration,input.board_position);target[1]+=f32::from_bits(0x3e4cccce); - self.blocked=false;self.query_pending=true;self.contact_height=target[1]-0.1; - Trajectory{position,velocity:sub(scale(sub(target,position),1./duration),scale(GRAVITY,duration*0.5)),acceleration:GRAVITY,duration} - } +impl Manager { + ///82D78B28: notably does NOT clear proposed96 or outstanding query262. + pub fn reset(&mut self) { + self.trajectory_32 = EMPTY; + self.elapsed_160 = 0.; + self.trajectory_valid_164 = false; + self.ik_offset_176 = [0.; 4]; + self.vector_192 = [0.; 4]; + self.moving_contact_208 = [0.; 4]; + self.vector_224 = [0.; 4]; + self.obstruction_height_240 = 0.; + self.time_to_land_244 = 0.; + self.proposed_time_248 = 0.; + self.completed_queries_252 = 0; + self.can_land_256 = false; + self.force_257 = false; + self.blocked_258 = false; + self.tested_259 = false; + self.hippy_hurdling_260 = false; + self.publish_moving_contact_261 = false; + } + + ///82D78C38: preserve the native correction sign and positive-frame guard. + pub fn correct_trajectory(&mut self, com: Vector) { + let error = sub(self.trajectory_32.position_at(self.elapsed_160), com); + let remaining = frames(self.time_to_land_244); + if remaining > 0 { + self.trajectory_32.position = add(self.trajectory_32.position, error); + adjust(&mut self.trajectory_32, remaining, error.map(|v| -v), 0.5); + } + } + + ///82D79420. None means no write to skeleton3482/48, not clear those fields. + pub fn fill(&self) -> FillOutput { + FillOutput { + can_land_316: self.can_land_256 && !self.blocked_258, + hippy_hurdling_317: self.hippy_hurdling_260 && !self.blocked_258, + moving_contact: self + .publish_moving_contact_261 + .then_some(self.moving_contact_208), + } + } } diff --git a/crates/skate-core/src/player/offboard/landing_deck/query.rs b/crates/skate-core/src/player/offboard/landing_deck/query.rs new file mode 100644 index 00000000..9e0e0638 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_deck/query.rs @@ -0,0 +1,130 @@ +//! Obstruction request82D79948, moving-contact Sync82D79B40 and79E30. +use super::trajectory::{frames, shift}; +use super::*; +use crate::player::wipeout_state::math::{add, dot, madd, reciprocal, scale, sub}; + +impl Manager { + ///82D79948 numeric request. Host submits with processed filter2952. + ///Native radius=.3, duration=time, start/end error=.5; no fake world hit. + ///Pending is NOT changed here: native sets it after the actual dispatch. + pub(super) fn prepare_query(&mut self, p: &Input, position: Vector, time: f32) -> QueryRequest { + let target = add( + madd(p.board_velocity_400, time, p.board_position_112), + [0., f32::from_bits(0x3e4c_ccce), 0., 0.], + ); + self.blocked_258 = false; + self.obstruction_height_240 = target[1] + -0.1; + QueryRequest { + trajectory: Trajectory { + position, + velocity: sub( + scale(sub(target, position), reciprocal(time)), + scale(scale(GRAVITY, 0.5), time), + ), + acceleration: GRAVITY, + duration: time, + }, + radius: 0.3, + start_error: 0.5, + end_error: 0.5, + } + } + + ///Call only after the returned request has actually been submitted. + ///On host submission failure retain/retry that request or propagate failure; + ///do not acknowledge it, fabricate a miss, or continue as if it succeeded. + pub fn query_submitted(&mut self) { + self.pending_262 = true; + } + + ///Consume one COMPLETED real query. None (or an explicit invalid result) + ///means no collision; it never means "query not ready". Caller gates pending. + ///The callback is the real79F00 provider and is invoked only by79E30's gate. + ///Its error leaves manager state unchanged so the host can propagate/retry. + pub fn sync( + &mut self, + hit: Option, + input: &SyncInput, + hippy_velocity: impl FnOnce(&Manager) -> Result, + ) -> Result<(), E> { + let mut next = *self; + next.sync_completed(hit.filter(|hit| hit.valid()), input, hippy_velocity)?; + *self = next; + Ok(()) + } + + fn sync_completed( + &mut self, + hit: Option, + input: &SyncInput, + hippy_velocity: impl FnOnce(&Manager) -> Result, + ) -> Result<(), E> { + if let Some(hit) = hit { + //82D79B9C uses strict less-than: equality is an obstruction. + if hit.contact_position[1] < self.obstruction_height_240 { + self.blocked_258 = false; + self.tested_259 = true; + } else if self.tested_259 { + self.blocked_258 = true; + } else if self.completed_queries_252 == 0 { + self.moving_contact_208 = hit.contact_transform[3]; + self.publish_moving_contact_261 = true; + } else { + let contact = hit.contact_transform[3]; //native result+112, NOT normal + let velocity = scale(sub(contact, self.moving_contact_208), 60.); + if dot(velocity, velocity) > f32::from_bits(0x3efa_e147) { + self.hippy_hurdling_260 = true; + if self.completed_queries_252 == 1 { + if input.flags_2488 & 0x0400_0000 != 0 { + //79E30 compares full-precision elapsed velocity BEFORE + //quantizing the epoch shift to integer frames. + let current = self.trajectory_32.velocity_at(self.elapsed_160); + let replacement = hippy_velocity(self)?; + if replacement[1] > current[1] { + let frame = frames(self.elapsed_160); + shift(&mut self.trajectory_32, frame); + self.trajectory_32.velocity = replacement; + shift(&mut self.trajectory_32, frame.wrapping_neg()); + } + } + } else { + let landing_time = self.time_to_land_244 + self.elapsed_160; + let predicted_contact = madd(velocity, self.time_to_land_244, contact); + let mut separation = sub( + self.trajectory_32.position_at(landing_time), + predicted_contact, + ); + separation[1] = 0.; + let square = dot(separation, separation); + if self.trajectory_32.velocity_at(self.elapsed_160)[1] < 0. { + if input.position_592[1] - hit.contact_position[1] < 0.7 { + self.blocked_258 = true; + self.tested_259 = true; + } + } else if square < 1. { + self.blocked_258 = true; + self.tested_259 = true; + } else if square > 6.25 { + //Big-endian decompiled sth1 at+258 is bytes00,01. + //Saved disassembly79DDC/DE0 proves these two stores. + self.blocked_258 = false; + self.tested_259 = true; + } + //Intermediate separation [1,6.25] retains both flags. + } + } else { + self.hippy_hurdling_260 = false; + self.blocked_258 = true; + self.tested_259 = true; + } + self.moving_contact_208 = contact; + } + } else { + self.blocked_258 = false; + self.tested_259 = true; + } + self.pending_262 = false; + self.completed_queries_252 = self.completed_queries_252.wrapping_add(1); + Ok(()) + } +} diff --git a/crates/skate-core/src/player/offboard/landing_deck/trajectory.rs b/crates/skate-core/src/player/offboard/landing_deck/trajectory.rs new file mode 100644 index 00000000..74640371 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_deck/trajectory.rs @@ -0,0 +1,111 @@ +//! Original82D78EE8 and its82D60B98/82D60C80,82D609E0,82D60B00 arithmetic. +//! Uses the existing Trajectory evaluations; no alternative trajectory owner. +use super::{GRAVITY, Input, Manager, STEP, Settings, Trajectory, Vector}; +use crate::physics::board_motion_output::inverse_length_squared; +use crate::player::wipeout_state::math::{add, dot, length, madd, reciprocal, scale, sub}; + +pub(super) fn frames(time: f32) -> i32 { + (time * f32::from_bits(0x426f_ffff)) as i32 +} +fn square_root(value: f32) -> f32 { + if value == 0. { + 0. + } else { + value * inverse_length_squared(value, 2) + } +} + +///82D60C80 ->82D60B98. No invented linear/zero-acceleration fallback. +///The one-root wrapper requires positive time; the two-root wrapper does not. +fn greatest_plane_time(t: Trajectory, point: Vector, normal: Vector) -> Option { + let a = dot(normal, scale(t.acceleration, reciprocal(2.))); + let b = dot(normal, t.velocity); + let c = dot(normal, t.position) - dot(point, normal); + let discriminant = b * b - (4. * a) * c; + if discriminant < 0. { + None + } else if discriminant > 0. { + let root = square_root(discriminant); + let inverse = reciprocal(2. * a); + Some((inverse * (-b + root)).max(inverse * (-b - root))) + } else { + let time = reciprocal(2. * a) * -b; + (time > 0.).then_some(time) + } +} + +pub(super) fn adjust(t: &mut Trajectory, frame: i32, delta: Vector, maximum: f32) { + if frame <= 0 { + return; + } + let correction = scale(delta, reciprocal(frame as f32 * STEP)); + let factor = if dot(correction, correction) > maximum * maximum { + maximum / length(correction) + } else { + 1. + }; + t.velocity = madd(correction, factor, t.velocity); +} +///82D60B00 leaves duration untouched. +pub(super) fn shift(t: &mut Trajectory, frame: i32) { + let time = frame as f32 * STEP; + let position = t.position_at(time); + t.velocity = t.velocity_at(time); + t.position = position; +} + +impl Manager { + ///82D78EE8. Failed intersection clears only can-land; other fields retain. + pub(super) fn consider_board( + &mut self, + p: &Input, + settings: &Settings, + position: Vector, + velocity: Vector, + ) { + let airborne_board = p.wheel_contacts_2556 == 0; + let mut board_velocity = p.board_velocity_400; + if airborne_board { + board_velocity[1] = 0.; + } + let relative = Trajectory { + position, + velocity: sub(velocity, board_velocity), + acceleration: if airborne_board { + scale(GRAVITY, 0.5) + } else { + GRAVITY + }, + duration: -1., + }; + let target = madd( + p.up_544, + settings.approximate_com_height, + p.board_position_112, + ); + let Some(time) = greatest_plane_time(relative, target, p.up_544).filter(|t| *t > 0.) else { + self.can_land_256 = false; + return; + }; + //822F8BD4=9.8;82060C50=2. Min-speed time is clamped to [.75t,1.3t]. + let distance = length(sub(position, target)); + let optimal = square_root(reciprocal(f32::from_bits(0x411c_cccd)) * (2. * distance)); + let proposed_time = (time * 1.3).min((time * 0.75).max(optimal)); + self.proposed_96 = Trajectory { + position, + velocity: add( + sub( + scale(sub(target, position), reciprocal(proposed_time)), + scale(scale(GRAVITY, 0.5), proposed_time), + ), + p.board_velocity_400, + ), + acceleration: GRAVITY, + duration: -1., + }; + self.ik_offset_176 = sub(target, relative.position_at(time)); + self.ik_offset_176[1] = 0.; + self.time_to_land_244 = time; + self.proposed_time_248 = proposed_time; + } +} diff --git a/crates/skate-core/src/player/offboard/landing_deck/types.rs b/crates/skate-core/src/player/offboard/landing_deck/types.rs new file mode 100644 index 00000000..b2e087d5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_deck/types.rs @@ -0,0 +1,69 @@ +//! Typed boundaries of original82D78xxx/82D79xxx. No synthetic input defaults. +use super::{Frame, QueryRequest, Vector}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Settings { + ///physics_landingondeck layout+4; strict board-up.Y comparison. + pub deck_min_uprightness: f32, + ///physics_landingondeck layout+24; plane point=deck112+up544*height. + pub approximate_com_height: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Input { + pub board_up_80: Vector, + pub board_position_112: Vector, + pub board_velocity_400: Vector, + pub up_544: Vector, + pub support_1776: i32, + pub flags_2480: u32, + pub mode_2540: u32, + pub wheel_contacts_2556: u32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AssistInput { + pub processed: Input, + pub position: Vector, + pub velocity: Vector, + pub maximum_velocity_change: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IkInput { + pub processed: Input, + pub time_to_land: f32, + pub animation_root: Frame, + pub animation_com_10960: Vector, + pub mapped_position_12608: Vector, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SyncInput { + pub position_592: Vector, + pub flags_2488: u32, +} +///Complete numeric result82D794A0; offsets describe original output, not Rust ABI. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct UpdateOutput { + pub can_land: bool, //0 + pub trajectory_valid: bool, //1 + pub time_to_land: f32, //4 + pub elapsed: f32, //8 + pub landing_time: f32, //12 + pub apex_time: f32, //16 + pub position: Vector, //32 + pub landing_velocity: Vector, //48 + pub launch_position: Vector, //64 + pub landing_position: Vector, //80 + pub normal: Vector, //96 + pub apex_position: Vector, //112 + pub direction: Vector, //128 + pub up: Vector, //144 + ///Host must additionally use actual processed filter2952 and submit before + ///query_submitted(). Native request has radius.3, start/end error.5. + pub query: Option, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FillOutput { + pub can_land_316: bool, + pub hippy_hurdling_317: bool, + ///Some sets skeleton3482 and writes skeleton48; None leaves both untouched. + pub moving_contact: Option, +} diff --git a/crates/skate-core/src/player/offboard/landing_deck/update.rs b/crates/skate-core/src/player/offboard/landing_deck/update.rs new file mode 100644 index 00000000..c9c5f958 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_deck/update.rs @@ -0,0 +1,103 @@ +//! Ordered Air assistance82D78D30, manager Update82D794A0 and IK82D792D0. +use super::*; +use crate::player::wipeout_state::math::{add, dot, length, madd, normalize_or, scale, sub}; +const UP: Vector = [0., 1., 0., 0.]; + +impl Manager { + ///82D78D30. No pending guard is added: the host's real batch must preserve + ///the original Begin/submit ordering when replacing an outstanding request. + pub fn assist(&mut self, input: &AssistInput, settings: &Settings) -> Option { + self.completed_queries_252 = 0; + self.tested_259 = false; + let p = &input.processed; + if (self.force_257 || p.flags_2480 & 0x8000 == 0) + && p.board_up_80[1] > settings.deck_min_uprightness + { + self.consider_board(p, settings, input.position, input.velocity); + if self.time_to_land_244 > 0.4 { + let error = length(sub(input.velocity, self.proposed_96.velocity)); + self.trajectory_32 = self.proposed_96; + self.trajectory_valid_164 = true; + self.time_to_land_244 = self.proposed_time_248; + self.can_land_256 = error < input.maximum_velocity_change; + } + if self.can_land_256 && !self.tested_259 { + self.tested_259 = true; + return Some(self.prepare_query(p, input.position, self.proposed_time_248)); + } + } + None + } + + ///82D794A0. This returns ALL numeric fields consumed by503; no target-frame + ///input is consumed by the original (r4 is its output structure). + pub fn update(&mut self, p: &Input, settings: &Settings) -> UpdateOutput { + self.publish_moving_contact_261 = false; + self.elapsed_160 += STEP; + if self.hippy_hurdling_260 { + let past_apex = self.elapsed_160 - self.trajectory_32.highest_position().1; + if past_apex > 0.3 || (past_apex > 0. && self.time_to_land_244 < 0.2) { + self.hippy_hurdling_260 = false; + } + } + let position = self.trajectory_32.position_at(self.elapsed_160); + let velocity = self.trajectory_32.velocity_at(self.elapsed_160); + self.can_land_256 = !matches!(p.mode_2540, 6 | 9 | 12); + self.consider_board(p, settings, position, velocity); + //Native query decision precedes the final error/possession/upright gate. + let query = if !self.pending_262 && !self.tested_259 && self.can_land_256 { + Some(self.prepare_query(p, position, self.time_to_land_244)) + } else { + None + }; + let maximum_error = if p.support_1776 < 0 { 0.55 } else { 0.5 }; + self.can_land_256 = length(self.ik_offset_176) < maximum_error + && (self.force_257 || p.flags_2480 & 0x8000 == 0) + && p.board_up_80[1] > settings.deck_min_uprightness; + let landing_time = self.time_to_land_244 + self.elapsed_160; + let landing_velocity = self.trajectory_32.velocity_at(landing_time); + let (apex_position, apex_time) = self.trajectory_32.highest_position(); + UpdateOutput { + can_land: self.can_land_256 && !self.blocked_258, + trajectory_valid: self.trajectory_valid_164, + time_to_land: self.time_to_land_244, + elapsed: self.elapsed_160, + landing_time, + apex_time, + position, + landing_velocity, + launch_position: self.trajectory_32.position, + landing_position: self.trajectory_32.position_at(landing_time), + normal: UP, + apex_position, + direction: normalize_or(landing_velocity, UP), + up: UP, + query, + } + } + + ///82D792D0. Disassembly792DC..79308 restores the decompiler's lost elapsed + ///sum. Root transforms the LOCAL mapped-position-minus-animation-COM vector. + pub fn calculate_accurate_ik_offset(&mut self, input: &IkInput) -> Vector { + let p = &input.processed; + let trajectory_time = self.elapsed_160 + input.time_to_land; + let deck_time = input.time_to_land + STEP; + let local = sub(input.mapped_position_12608, input.animation_com_10960); + let root = input.animation_root; + let offset = madd( + root[2], + local[2], + madd(root[1], local[1], scale(root[0], local[0])), + ); + let tangent_velocity = sub( + p.board_velocity_400, + scale(p.up_544, dot(p.up_544, p.board_velocity_400)), + ); + self.ik_offset_176 = sub( + madd(tangent_velocity, deck_time, p.board_position_112), + add(self.trajectory_32.position_at(trajectory_time), offset), + ); + self.ik_offset_176[1] = 0.; + self.ik_offset_176 + } +} diff --git a/crates/skate-core/src/player/offboard/landing_state.rs b/crates/skate-core/src/player/offboard/landing_state.rs new file mode 100644 index 00000000..c6a86064 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_state.rs @@ -0,0 +1,154 @@ +//! State503, original TU3 82D4D418..82D4E100 (SHA431b8eba...0395a). +//! Manager owns the trajectory; this state owns alignment and landing decisions. +use super::{hippy_jump, landing_deck::{Manager, Trajectory, UpdateOutput, GRAVITY, STEP}}; +use crate::player::wipeout_state::{math::wrap_angle, orientation::projected_angle}; +use crate::physics::board_motion_output::inverse_length_squared; +pub type Vector = [f32;4]; +const UP: Vector = [0.,1.,0.,0.]; +const EPSILON: f32 = f32::from_bits(0x37800000); +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub minimum_auto_angle: f32, + pub automatic_speed: f32, + pub input_speed: f32, + pub input_delta: f32, + pub automatic_delta: f32, + pub maximum_landing_speed: f32, +} +#[derive(Default, Debug)] +pub struct State { + pub output: Option, + pub time_to_land: f32, + pub dangerous: bool, + pub near_deck: bool, + pub turning: bool, + pub hippy: bool, + pub takeoff_frames: i32, + pub spin_rate: f32, + pub applied_spin: f32, + pub transition_angle: f32, + pub accumulated_spin: f32, + pub half_turns: i32, + pub next_half_turns: i32, +} +pub struct Entry { + pub previous_category: u32, + pub hippy: bool, + pub strength: f32, + pub board_position: Vector, + pub com_position: Vector, + pub com_velocity: Vector, + pub board_velocity: Vector, + pub up: Vector, + pub hips_up: Vector, + pub animation_right: Vector, + pub reversed: bool, +} +impl State { + ///82D4D418/530/668: same manager as BipedAir; preservation depends on category. + pub fn enter(&mut self, manager: &mut Manager, input: Entry) { + let preserve = input.previous_category == 500; + if !preserve { manager.reset(); } + *self = Self::default(); + let right = input.animation_right.map(|v| if input.reversed {-v} else {v}); + self.transition_angle = wrap_angle(projected_angle(input.hips_up, right, UP)); + if input.hippy || !preserve { + let velocity = if input.hippy { + hippy_jump::calculate(hippy_jump::Input { + desired_height: (1.-input.strength)*1.35 + input.strength*1.65, + board_position: input.board_position, + centre_of_mass_position: input.com_position, + reference_up: input.up, + current_velocity: input.board_velocity, + }) + } else { input.com_velocity }; + manager.trajectory_32 = Trajectory { + position: input.com_position, velocity, acceleration: GRAVITY, duration: -1., + }; + manager.elapsed_160 = 0.; + manager.trajectory_valid_164 = true; + manager.force_257 = input.hippy; + if input.hippy { self.takeoff_frames = 2; } + } else { + manager.elapsed_160 += STEP; + manager.correct_trajectory(input.com_position); + } + self.hippy = input.hippy; + } + ///82D4D838..DAFC, after the manager update and before Skeleton82BDE428. + pub fn align(&mut self, s: &Settings, board_forward: Vector, + animation_forward: Vector, state_time: f32, com_velocity_y: f32, input_spin: f32) { + let frames = self.time_to_land * 59.999996; + let mut angle = projected_angle(board_forward, animation_forward, UP); + if state_time < 0.28 { + angle -= (1. - state_time*3.5714285)*self.transition_angle; + } + angle = wrap_angle(self.spin_rate*frames + angle); + self.turning = angle.abs() > std::f32::consts::FRAC_PI_2; + if self.time_to_land > EPSILON { + let rising = com_velocity_y > 0.; + let input = if rising && self.hippy {input_spin} else {0.}; + let (target, delta) = if input.abs() > EPSILON { + (-s.input_speed*input, s.input_delta) + } else { + if rising { + //82E09C80 folds the wrapped angle modulo pi, not modulo 2pi. + angle = wrap_angle(angle); + if angle.abs() > std::f32::consts::FRAC_PI_2 { + angle = (angle.abs()-std::f32::consts::PI)*angle.signum(); + } + } + let tolerance = s.minimum_auto_angle*0.017453292; + let target = if angle.abs() > tolerance { + let excess = if angle > 0. {(angle-tolerance).max(0.)} + else {(angle+tolerance).min(0.)}; + (self.spin_rate-excess/frames).clamp(-s.automatic_speed,s.automatic_speed) + } else {self.spin_rate}; + (target, s.automatic_delta) + }; + self.spin_rate = target.clamp(self.spin_rate-delta,self.spin_rate+delta); + self.applied_spin = self.spin_rate; + if state_time < 0.28 { + self.applied_spin = (-self.transition_angle).mul_add(0.059523813,self.spin_rate); + } + } + } + ///82D4DB10..DB74, called only after the skeleton update completes. + pub fn advance_spin(&mut self, output: UpdateOutput) { + self.accumulated_spin += self.applied_spin; + self.half_turns = (self.accumulated_spin*0.31830987) as i32; + self.next_half_turns = self.half_turns; + if self.accumulated_spin < 0. && self.applied_spin < 0. { + self.next_half_turns -= 1; + } else if self.accumulated_spin > 0. && self.applied_spin > 0. { + self.next_half_turns += 1; + } + self.time_to_land = output.time_to_land; + self.output = Some(output); + } + ///82D4DD80: actual physical toe-volume positions15/19, not IK targets. + pub fn accurate_time(board_y: f32, board_velocity_y: f32, com_velocity_y: f32, + toe_y: [f32;2], wheel_contacts: u32) -> f32 { + let feet = (toe_y[0]+toe_y[1])*0.5-0.15000001; + let velocity = com_velocity_y-board_velocity_y; + let displacement = board_y-feet; + let gravity = if wheel_contacts != 0 {-9.8} else {-4.9}; + let discriminant = velocity*velocity+(displacement*gravity)*2.; + if discriminant > 0. { + let root = discriminant*inverse_length_squared(discriminant,2); + (-velocity-root)/gravity + } else {0.} + } + ///82D4DCBC..DD58; leaves near_deck untouched while rising. + pub fn finish(&mut self, s: &Settings, com_velocity_y: f32) { + if self.takeoff_frames > 0 {self.takeoff_frames -= 1;} + self.dangerous = self.time_to_land < 0.07 + && com_velocity_y < -s.maximum_landing_speed; + } + pub fn landing_half_turns(&self) -> i32 { + if self.time_to_land >= 0.05 {self.half_turns} else {self.next_half_turns} + } + pub fn requests_board_flip(&self) -> bool {self.turning && self.time_to_land < 0.02} +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/landing_state/tests.rs b/crates/skate-core/src/player/offboard/landing_state/tests.rs new file mode 100644 index 00000000..4961b606 --- /dev/null +++ b/crates/skate-core/src/player/offboard/landing_state/tests.rs @@ -0,0 +1,51 @@ +use super::*; +fn settings()->Settings {Settings {minimum_auto_angle:5.,automatic_speed:0.1,input_speed:0.2, + input_delta:0.01,automatic_delta:0.02,maximum_landing_speed:10.}} +fn entry()->Entry {Entry {previous_category:100,hippy:true,strength:0.5, + board_position:[0.;4],com_position:[0.,1.,0.,0.],com_velocity:[0.;4], + board_velocity:[2.,0.,0.,0.],up:UP,hips_up:[1.,0.,0.,0.], + animation_right:[1.,0.,0.,0.],reversed:false}} +#[test] +fn hippy_initializes_the_shared_manager_and_follows_gravity() { + let mut state=State::default();let mut manager=Manager::default(); + state.enter(&mut manager,entry()); + assert!(manager.trajectory_valid_164 && manager.force_257); + assert_eq!(manager.trajectory_32.velocity[0],2.); + assert_eq!(manager.trajectory_32.acceleration,GRAVITY); + let t=manager.trajectory_32; + assert!(t.position_at(0.1)[1]>t.position[1]); + assert!(t.velocity_at(1.)[1]<0.); + assert!(t.position_at(1.)[1]0. && t<0.2); + let airborne=State::accurate_time(0.,0.,-1.,[0.3,0.3],0); + assert!(airborne>t); + assert_eq!(State::accurate_time(0.,0.,0.,[-2.,-2.],4),0.); +} diff --git a/crates/skate-core/src/player/offboard/mod.rs b/crates/skate-core/src/player/offboard/mod.rs index eb07418f..5c3622ae 100644 --- a/crates/skate-core/src/player/offboard/mod.rs +++ b/crates/skate-core/src/player/offboard/mod.rs @@ -1,45 +1,33 @@ //! Recovered off-board and shared recovery behavior. -pub mod board_possession; +pub mod twist_lean; pub mod cadence; -pub mod contact_candidates; -pub mod contact_classify; pub mod contact_correction; -pub mod contact_geometry; -pub mod contact_intersections; -pub mod contact_output; -pub mod contact_packet; -pub mod contact_promotion; -pub mod contact_queries; -pub mod contact_records; -pub mod contact_segments; -pub mod contact_simplify; -pub mod contact_sort; -pub mod contact_toolkit; -pub mod controller; -pub mod ground_branch; -pub mod ground_entry; pub mod ground_input; -pub mod ground_job; -pub mod ground_lifecycle; -pub mod ground_motion; -pub mod ground_query; -pub mod ground_reckoning; -pub mod ground_sync; +pub mod ground_branch; +pub mod position_output; +pub mod slide; pub mod movement_intent; pub mod movement_velocity; -pub mod pose_adjust; -pub mod position_output; -pub mod reset_animation; -pub mod slide; pub mod surface_frame; -pub mod twist_lean; - -pub mod air_collision; -pub mod air_completion; +pub mod ground_motion; +pub mod controller; +pub mod ground_entry; +pub mod ground_query; +pub mod ground_sync; +pub mod grab_scene; +pub mod ground_reckoning; +pub mod ground_lifecycle; +pub mod contact_toolkit; +pub mod pose_adjust; +pub mod toggle_board; +pub mod board_possession; +pub mod biped_air; +pub mod ground_job; pub mod air_launch; -pub mod air_ledge; -pub mod air_prediction; -pub mod air_queries; -pub mod air_state; -pub mod feet; +pub mod air_selector; +pub mod hippy_jump; pub mod landing_deck; +pub mod landing_state; + + +pub mod reset_animation; diff --git a/crates/skate-core/src/player/offboard/reset_animation/tests.rs b/crates/skate-core/src/player/offboard/reset_animation/tests.rs index 4d84b597..0f2a6d85 100644 --- a/crates/skate-core/src/player/offboard/reset_animation/tests.rs +++ b/crates/skate-core/src/player/offboard/reset_animation/tests.rs @@ -30,3 +30,21 @@ fn given_stance_retains_lower_flags_and_consumes_request() { assert_eq!(request, 0); } } + +#[test] +fn playback_reset_preserves_unrelated_flags_and_resets_only_meaningful_request_words() { + let mut flags = u32::MAX; + let mut mirrored = true; + let mut word_15320 = 17; + skater_flags(&mut flags, &mut mirrored, &mut word_15320); + assert_eq!(flags, 0x0ff7_ffff); + assert!(!mirrored); + assert_eq!(word_15320, 0); + // The local-player bit controls later animation culling and must survive. + assert_ne!(flags & 0x0800_0000, 0); + let mut request = [u32::MAX; 4]; + let mut active = true; + motion_request(&mut request, &mut active); + assert_eq!(request, [0, 0x3e4c_cccd, 0, 0]); + assert!(!active); +} diff --git a/crates/skate-core/src/player/offboard/toggle_board.rs b/crates/skate-core/src/player/offboard/toggle_board.rs new file mode 100644 index 00000000..f469a97c --- /dev/null +++ b/crates/skate-core/src/player/offboard/toggle_board.rs @@ -0,0 +1,241 @@ +//! ToggleBoard TU3 82BA8FE8, retrieval 82BA9310, drop 82BA9990. +//! Caller supplies completed physical observations and actual channel timing. +//! Commands retain the native TransitionTo (+16) versus SequenceTo (+20) distinction. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Phase { + #[default] + Idle, + RetrieveStart, + RetrieveInto, + RetrieveCycle, + RetrieveOut, + DropStart, + DropPlaying, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Input { + /// OffBoard304, OffBoard311, bundle28 byte87, OffBoard313. + pub grabbing_object: bool, + pub holding_board: bool, + pub retrieval_blocked: bool, + pub retrieval_active: bool, + pub drop_requested: bool, + pub throw_requested: bool, + pub retrieve_requested: bool, + /// OffBoard36/40 and animation component virtual28. + pub yaw_radians: f32, + pub pitch_radians: f32, + pub mirrored: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Channel { + pub exists: bool, + pub remaining: f32, + pub elapsed: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Clip { + Throw, + Drop, + FrontInto, + FrontCycle, + FrontOut, + BackInto, + BackCycle, + BackOut, +} +impl Clip { + /// Constructor82BA8EC8; drop deliberately selects THROW_90. + pub fn name(self) -> &'static str { + match self { + Self::Throw => "OFB_THROW_0", + Self::Drop => "OFB_THROW_90", + Self::FrontInto => "OFB_RETRIEVE_HIGH_FRONT_INTO", + Self::FrontCycle => "OFB_RETRIEVE_HIGH_FRONT_CYC", + Self::FrontOut => "OFB_RETRIEVE_HIGH_FRONT_OUT", + Self::BackInto => "OFB_RETRIEVE_HIGH_BACK_INTO", + Self::BackCycle => "OFB_RETRIEVE_HIGH_BACK_CYC", + Self::BackOut => "OFB_RETRIEVE_HIGH_BACK_OUT", + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Command { + BlendTo(Clip), + SequenceTo(Clip), + Stop { blend_seconds: f32 }, +} +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Output { + /// Append each true flag to MG physical attributes and playback parameters. + pub retrieving: bool, + pub dropping: bool, + /// One-tick OB_RetrieveBoard publication on each cycle transition. + pub retrieve: bool, + /// Retrieval branches publish yaw then pitch, even on the terminating tick. + pub yaw_pitch: Option<[f32; 2]>, + pub channel: Option, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct State { + pub phase: Phase, + previous_yaw: f32, + yaw: f32, + pitch: f32, + initialized_yaw: bool, + back: bool, + throwing: bool, +} +impl State { + /// Begin82BA8FD8 resets phase then tail-calls ResetYawPitch82BA9D08. + pub fn begin(&mut self) { + self.phase = Phase::Idle; + self.reset_orientation(); + } + fn reset_orientation(&mut self) { + self.previous_yaw = 0.0; + self.yaw = 0.0; + self.pitch = 0.0; + self.initialized_yaw = false; + self.back = false; + } + /// 82BA9E20; five degrees per update, independent of delta time. + fn orientation(&mut self, input: Input) { + let mut yaw = input.yaw_radians * -57.295776_f32; + if input.mirrored { + yaw = -yaw; + } + if yaw < -90.0 { + yaw = if self.initialized_yaw { + if self.yaw >= 0.0 { 180.0 } else { -90.0 } + } else if yaw >= -135.0 { + -90.0 + } else { + 180.0 + }; + } + if input.mirrored { + yaw = -yaw; + } + if self.initialized_yaw { + let difference = yaw - self.previous_yaw; + // Preserve the original ordered fsel arithmetic, including NaNs. + let lower = if -5.0 - difference >= 0.0 { + -5.0 + } else { + difference + }; + let step = if 5.0 - lower >= 0.0 { lower } else { 5.0 }; + yaw = step + self.previous_yaw; + } + self.previous_yaw = yaw; + self.yaw = if input.mirrored { -yaw } else { yaw }; + self.pitch = input.pitch_radians * 57.295776_f32; + self.initialized_yaw = true; + self.back = self.yaw.abs() > 90.0; + } + pub fn update(&mut self, input: Input, channel: Channel) -> Output { + if !input.grabbing_object && self.phase == Phase::Idle { + if input.drop_requested && input.holding_board { + self.phase = Phase::DropStart; + self.throwing = false; + } else if input.throw_requested && input.holding_board { + self.phase = Phase::DropStart; + self.throwing = true; + } else if input.retrieve_requested && !input.holding_board && !input.retrieval_blocked { + self.phase = Phase::RetrieveStart; + } + } + let mut out = Output::default(); + match self.phase { + Phase::Idle => self.reset_orientation(), + Phase::DropStart => { + out.dropping = true; + self.phase = Phase::DropPlaying; + out.channel = Some(Command::BlendTo(if self.throwing { + Clip::Throw + } else { + Clip::Drop + })); + } + Phase::DropPlaying => { + out.dropping = true; + if !channel.exists || channel.remaining <= 0.0 || channel.elapsed > 0.5 { + self.stop(&mut out); + } + } + phase => { + out.retrieving = true; + match phase { + Phase::RetrieveStart => { + self.orientation(input); + self.phase = Phase::RetrieveInto; + out.channel = Some(Command::BlendTo(if self.back { + Clip::BackInto + } else { + Clip::FrontInto + })); + } + Phase::RetrieveInto => { + self.orientation(input); + if !channel.exists || channel.remaining <= 0.0 { + self.phase = Phase::Idle; + } else if channel.remaining <= 0.25 { + self.cycle(&mut out); + } + } + Phase::RetrieveCycle => { + self.orientation(input); + if !input.retrieval_active { + self.stop(&mut out); + } else if !channel.exists || channel.remaining <= 0.0 { + self.phase = Phase::Idle; + } else if input.holding_board { + self.phase = Phase::RetrieveOut; + out.channel = Some(Command::BlendTo(if self.back { + Clip::BackOut + } else { + Clip::FrontOut + })); + } else if channel.remaining <= 0.25 { + self.cycle(&mut out); + } + } + Phase::RetrieveOut => { + if !channel.exists + || channel.remaining <= 0.0 + || channel.elapsed > 0.16666667 + { + self.stop(&mut out); + } + } + _ => unreachable!(), + } + out.yaw_pitch = Some([self.yaw, self.pitch]); + } + } + out + } + fn cycle(&mut self, out: &mut Output) { + self.phase = Phase::RetrieveCycle; + out.channel = Some(Command::SequenceTo(if self.back { + Clip::BackCycle + } else { + Clip::FrontCycle + })); + out.retrieve = true; + } + fn stop(&mut self, out: &mut Output) { + self.phase = Phase::Idle; + out.channel = Some(Command::Stop { + blend_seconds: 0.16666667, + }); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/toggle_board/tests.rs b/crates/skate-core/src/player/offboard/toggle_board/tests.rs new file mode 100644 index 00000000..ae9acf61 --- /dev/null +++ b/crates/skate-core/src/player/offboard/toggle_board/tests.rs @@ -0,0 +1,224 @@ +use super::*; + +fn live(remaining: f32, elapsed: f32) -> Channel { + Channel { + exists: true, + remaining, + elapsed, + } +} +fn retrieve() -> Input { + Input { + retrieve_requested: true, + retrieval_active: true, + ..Input::default() + } +} + +#[test] +fn retrieval_runs_into_cycle_and_out_with_pulse_only_on_cycle_entry() { + let mut state = State::default(); + let mut input = retrieve(); + let start = state.update(input, Channel::default()); + assert_eq!(start.channel, Some(Command::BlendTo(Clip::FrontInto))); + assert!(start.retrieving && !start.retrieve); + assert_eq!(state.update(input, live(0.251, 0.0)).channel, None); + let cycle = state.update(input, live(0.25, 0.0)); + assert_eq!(cycle.channel, Some(Command::SequenceTo(Clip::FrontCycle))); + assert!(cycle.retrieve); + assert!(!state.update(input, live(0.4, 0.0)).retrieve); + assert!(state.update(input, live(0.25, 0.0)).retrieve); + input.holding_board = true; + let out = state.update(input, live(0.4, 0.0)); + assert_eq!(out.channel, Some(Command::BlendTo(Clip::FrontOut))); + assert!(!out.retrieve); + assert_eq!(state.update(input, live(0.4, 0.16666667)).channel, None); + let end = state.update(input, live(0.4, 0.17)); + assert_eq!( + end.channel, + Some(Command::Stop { + blend_seconds: 0.16666667 + }) + ); + assert!(end.retrieving && end.yaw_pitch.is_some()); + assert_eq!(state.phase, Phase::Idle); + input.retrieve_requested = false; + assert_eq!(state.update(input, Channel::default()), Output::default()); +} + +#[test] +fn drop_has_precedence_over_throw_and_playout_boundary_is_strict() { + let mut state = State::default(); + let input = Input { + holding_board: true, + drop_requested: true, + throw_requested: true, + ..Input::default() + }; + assert_eq!( + state.update(input, Channel::default()).channel, + Some(Command::BlendTo(Clip::Drop)) + ); + assert_eq!(state.update(input, live(1.0, 0.5)).channel, None); + assert!(matches!( + state.update(input, live(1.0, 0.5001)).channel, + Some(Command::Stop { .. }) + )); + let input = Input { + drop_requested: false, + ..input + }; + assert_eq!( + state.update(input, Channel::default()).channel, + Some(Command::BlendTo(Clip::Throw)) + ); +} + +#[test] +fn physical_guards_prevent_new_requests_but_do_not_cancel_running_drop() { + let mut state = State::default(); + assert_eq!( + state.update( + Input { + retrieval_blocked: true, + ..retrieve() + }, + Channel::default() + ), + Output::default() + ); + assert_eq!( + state.update( + Input { + grabbing_object: true, + ..retrieve() + }, + Channel::default() + ), + Output::default() + ); + assert_eq!( + state.update( + Input { + holding_board: true, + ..retrieve() + }, + Channel::default() + ), + Output::default() + ); + let drop = Input { + holding_board: true, + drop_requested: true, + ..Input::default() + }; + state.update(drop, Channel::default()); + let out = state.update( + Input { + grabbing_object: true, + ..drop + }, + live(0.6, 0.1), + ); + assert!(out.dropping); + assert_eq!(state.phase, Phase::DropPlaying); +} + +#[test] +fn lost_retrieval_feedback_stops_before_possession_or_channel_checks() { + let mut state = State::default(); + state.update(retrieve(), Channel::default()); + state.update(retrieve(), live(0.25, 0.0)); + let out = state.update( + Input { + retrieval_active: false, + holding_board: true, + ..retrieve() + }, + Channel::default(), + ); + assert!(matches!(out.channel, Some(Command::Stop { .. }))); + assert!(!out.retrieve); +} + +#[test] +fn expired_into_channel_returns_idle_without_sending_stop() { + for channel in [Channel::default(), live(0.0, 1.0)] { + let mut state = State::default(); + state.update(retrieve(), Channel::default()); + let out = state.update(retrieve(), channel); + assert_eq!(state.phase, Phase::Idle); + assert_eq!(out.channel, None); + assert!(out.retrieving); + } +} + +#[test] +fn yaw_uses_back_selection_and_five_degree_per_update_limit() { + let mut state = State::default(); + let input = Input { + yaw_radians: -2.0, + pitch_radians: 0.5, + ..retrieve() + }; + let out = state.update(input, Channel::default()); + assert_eq!(out.channel, Some(Command::BlendTo(Clip::BackInto))); + let first = out.yaw_pitch.unwrap(); + assert_eq!(first, [2.0 * 57.295776, 0.5 * 57.295776]); + let next = state + .update( + Input { + yaw_radians: 0.0, + ..input + }, + live(1.0, 0.0), + ) + .yaw_pitch + .unwrap(); + assert_eq!(next[0], first[0] - 5.0); + state.begin(); + let reset = state.update( + Input { + yaw_radians: 0.0, + ..input + }, + Channel::default(), + ); + assert_eq!(reset.channel, Some(Command::BlendTo(Clip::FrontInto))); + assert_eq!(reset.yaw_pitch.unwrap()[0], 0.0); +} + +#[test] +fn mirrored_yaw_applies_native_asymmetric_rear_sector_rule() { + let input = Input { + yaw_radians: 2.0, + ..retrieve() + }; + let mut normal = State::default(); + let out = normal.update(input, Channel::default()); + assert_eq!(out.yaw_pitch.unwrap()[0], -90.0); + assert_eq!(out.channel, Some(Command::BlendTo(Clip::FrontInto))); + let mut mirrored = State::default(); + let out = mirrored.update( + Input { + mirrored: true, + ..input + }, + Channel::default(), + ); + assert_eq!(out.yaw_pitch.unwrap()[0], 2.0 * 57.295776); + assert_eq!(out.channel, Some(Command::BlendTo(Clip::BackInto))); + let mut rear = State::default(); + assert_eq!( + rear.update( + Input { + yaw_radians: 3.0, + ..input + }, + Channel::default() + ) + .yaw_pitch + .unwrap()[0], + 180.0 + ); +} diff --git a/crates/skate-core/src/player/post_input.rs b/crates/skate-core/src/player/post_input.rs index 23219d16..3d1a3642 100644 --- a/crates/skate-core/src/player/post_input.rs +++ b/crates/skate-core/src/player/post_input.rs @@ -214,6 +214,16 @@ fn replace_bit(word: &mut u32, bit: u32, value: u32) { *word = (*word & !(1 << bit)) | ((value & 1) << bit); } +///82762AB0 copies the defined fields, preserving destination padding and the +///low five bits of byte200. Words use native big-endian bit positions. +pub fn copy_grab_record_82762ab0(destination: &mut [u32; 72], source: &[u32; 72]) { + destination[..50].copy_from_slice(&source[..50]); + destination[50] = (destination[50] & 0x1fff_ffff) | (source[50] & 0xe000_0000); + destination[51..55].copy_from_slice(&source[51..55]); + destination[56..66].copy_from_slice(&source[56..66]); + destination[68] = source[68]; +} + #[cfg(test)] #[path = "tests/post_input.rs"] mod tests; diff --git a/crates/skate-core/src/player/selector/air.rs b/crates/skate-core/src/player/selector/air.rs index 861d3667..5ca6c10c 100644 --- a/crates/skate-core/src/player/selector/air.rs +++ b/crates/skate-core/src/player/selector/air.rs @@ -20,8 +20,8 @@ impl StateSelector { if p.has_2476(0x8000) { return PhysicalStateId::BipedGround; } - //Native lbz2484 selects the high byte of the big-endian flag word. - if p.has_2476(0x80) && !p.has_2484(0x100_0000) { + //82D8B490 lbz2484 reads the high byte of the big-endian flag word. + if p.has_2476(0x80) && !p.has_2484(0x0100_0000) { return PhysicalStateId::BipedAir; } if facts.force_known_air { @@ -73,7 +73,8 @@ impl StateSelector { { return PhysicalStateId::WipeoutGround; } - if p.has_2476(0x80) && !p.has_2484(0x100_0000) { + //82D8B5F0 uses the same byte load for the offboard dismount gate. + if p.has_2476(0x80) && !p.has_2484(0x0100_0000) { return PhysicalStateId::BipedAir; } if p.has_2476(0x8000) { diff --git a/crates/skate-core/src/player/selector/offboard.rs b/crates/skate-core/src/player/selector/offboard.rs index e2acb71a..c36015a2 100644 --- a/crates/skate-core/src/player/selector/offboard.rs +++ b/crates/skate-core/src/player/selector/offboard.rs @@ -35,9 +35,9 @@ impl StateSelector { return PhysicalStateId::WipeoutGround; } if !p.has_2480(0x10) { - //82D8ADE8's BipedAir branch reads lhz2484, then tests its low bit. On - //big-endian Xenon this is full-word bit16 (Offboard328), - //not bit0 (the unrelated BipedBoardOnGround attribute). + //82D8B8D4 loads a BIG-ENDIAN halfword at2484, then + //B8D8 tests its low bit: bit16 of the stored u32. + //This is OffBoard328, not BipedBoardOnGround bit0. if p.has_2484(0x1_0000) { if p.has_2476(0x8000) || p.has_2476(0x80) { current @@ -113,3 +113,7 @@ impl StateSelector { } } } + +#[cfg(test)] +#[path = "tests/offboard.rs"] +mod tests; diff --git a/crates/skate-core/src/player/selector/tests/offboard.rs b/crates/skate-core/src/player/selector/tests/offboard.rs new file mode 100644 index 00000000..d563acdf --- /dev/null +++ b/crates/skate-core/src/player/selector/tests/offboard.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::player::selector::{ + ProcessedStateInput, + conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}, +}; + +fn input(flags_2484: u32, flags_2476: u32, flags_2480: u32) -> StateSelectionInput { + let thresholds = TwoStageThresholds { + field_856_primary: 1., field_856_secondary: 1., field_7692: 1., + }; + StateSelectionInput { + processed: ProcessedStateInput { + grind_type_1248: 0, grind_candidate_1488: false, + grind_investigation_flags_1516: 0, field_1776: 0, + flags_2468: 0, flags_2472: 0, flags_2476, flags_2480, + flags_2484, flags_2488: 0, category_2512: 500, + wheel_contact_count_2556: 0, field_2572: 0, + state_timer_2664: 0., field_2732: 0., field_2744: 0., + trajectory_collision_time_2772: 1., + }, + skateboard_contact_count_869: 0, + board_body: BoardBodyState { field_856: 0., field_7692: 0. }, + skeleton: SkeletonAnimationState { + mode_16420: 0, back_chain_lane_12656: 0., threshold_800: 1., + }, + normal_off_ground: thresholds, skitching_off_ground: thresholds, + } +} + +#[test] +fn biped_air_uses_published_air_flag_not_board_on_ground_animation_flag() { + for animation_board_on_ground in [0, 1] { + let mut selector = StateSelector::default(); + let airborne = input(0x1_0000 | animation_board_on_ground, 0x80, 0); + assert_eq!(selector.calculate(PhysicalStateId::BipedAir, &airborne), + PhysicalStateId::BipedAir); + let landed = input(animation_board_on_ground, 0x80, 0); + assert_eq!(selector.calculate(PhysicalStateId::BipedAir, &landed), + PhysicalStateId::BipedGround); + } +} + +#[test] +fn air_to_ground_does_not_oscillate_while_published_air_flag_remains_set() { + let mut selector = StateSelector::default(); + let airborne = input(0x1_0000, 0x8000, 0); + let mut state = PhysicalStateId::BipedGround; + for _ in 0..60 { + state = selector.calculate(state, &airborne); + assert_eq!(state, PhysicalStateId::BipedAir); + } + state = selector.calculate(state, &input(0, 0x8000, 0)); + assert_eq!(state, PhysicalStateId::BipedGround); +} + +#[test] +fn actual_mount_and_land_on_board_requests_keep_their_native_precedence() { + let mut selector = StateSelector::default(); + assert_eq!(selector.calculate(PhysicalStateId::BipedAir, &input(0x1_0000, 0, 0)), + PhysicalStateId::PhysicsAir); + assert_eq!(selector.calculate(PhysicalStateId::BipedAir, &input(0x1_0000, 0x80, 0x10)), + PhysicalStateId::LandingOnDeck); +} + +#[test] +fn airborne_dismount_reads_high_byte_gate_in_both_onboard_air_states() { + for current in [PhysicalStateId::PhysicsAir, PhysicalStateId::KnownAir] { + let mut selector = StateSelector::default(); + for board_on_ground in [0, 1] { + let allowed = input(board_on_ground, 0x80, 0); + assert_eq!(selector.calculate(current, &allowed), PhysicalStateId::BipedAir); + let blocked = input(0x0100_0000 | board_on_ground, 0x80, 0); + assert_ne!(selector.calculate(current, &blocked), PhysicalStateId::BipedAir); + } + } +} diff --git a/crates/skate-core/src/player/tests/lifecycle.rs b/crates/skate-core/src/player/tests/lifecycle.rs index bcb5c936..0c05560f 100644 --- a/crates/skate-core/src/player/tests/lifecycle.rs +++ b/crates/skate-core/src/player/tests/lifecycle.rs @@ -37,11 +37,11 @@ impl PhysicalStateCalls for SharedRecorder { } impl SkateboardControllerActions for SharedRecorder { - fn hold_skateboard(&mut self, _fields: &mut SkateboardControllerFields) { + fn hold_skateboard(&mut self) { self.events.borrow_mut().push(Event::Hold); } - fn let_go_of_skateboard(&mut self, _fields: &mut SkateboardControllerFields) { + fn let_go_of_skateboard(&mut self) { self.events.borrow_mut().push(Event::LetGo); } } @@ -62,11 +62,11 @@ impl PhysicalStateCalls for Recorder { } impl SkateboardControllerActions for Recorder { - fn hold_skateboard(&mut self, _fields: &mut SkateboardControllerFields) { + fn hold_skateboard(&mut self) { self.events.push(Event::Hold); } - fn let_go_of_skateboard(&mut self, _fields: &mut SkateboardControllerFields) { + fn let_go_of_skateboard(&mut self) { self.events.push(Event::LetGo); } } @@ -97,41 +97,6 @@ fn seeded_data() -> StateChangeData { } } -#[test] -fn release_callback_retains_its_throw_countdown_through_state_publication() { - struct Throw; - impl SkateboardControllerActions for Throw { - fn hold_skateboard(&mut self, _: &mut SkateboardControllerFields) { - panic!("unexpected hold"); - } - fn let_go_of_skateboard(&mut self, fields: &mut SkateboardControllerFields) { - assert_eq!(fields.word_444, 0); - fields.word_444 = 8; - } - } - let mut data = seeded_data(); - data.skateboard_controller = SkateboardControllerFields { - word_444: 123, - state_448: 1, - system_on_452: true, - }; - let mut states = Recorder { - reported_type: PhysicalStateId::BipedGround, - events: vec![], - }; - PhysicalPlayerStateLifecycle::new(PhysicalStateId::BipedGround) - .set_physics_state( - PhysicalStateId::PhysicsGround as u32, - &mut data, - &mut states, - &mut Throw, - ) - .unwrap(); - assert_eq!(data.skateboard_controller.word_444, 8); - assert_eq!(data.skateboard_controller.state_448, 0); - assert!(!data.skateboard_controller.system_on_452); -} - fn recorder(reported_type: PhysicalStateId) -> Recorder { Recorder { reported_type, diff --git a/crates/skate-core/src/player/tests/logic.rs b/crates/skate-core/src/player/tests/logic.rs index 61740dcd..3929ed5d 100644 --- a/crates/skate-core/src/player/tests/logic.rs +++ b/crates/skate-core/src/player/tests/logic.rs @@ -43,8 +43,8 @@ impl PhysicalStateCalls for States { struct Controller; impl SkateboardControllerActions for Controller { - fn hold_skateboard(&mut self, _fields: &mut SkateboardControllerFields) {} - fn let_go_of_skateboard(&mut self, _fields: &mut SkateboardControllerFields) {} + fn hold_skateboard(&mut self) {} + fn let_go_of_skateboard(&mut self) {} } fn input() -> StateSelectionInput { diff --git a/crates/skate-core/src/player/wipeout/air.rs b/crates/skate-core/src/player/wipeout/air.rs index b564f66c..306fb9ff 100644 --- a/crates/skate-core/src/player/wipeout/air.rs +++ b/crates/skate-core/src/player/wipeout/air.rs @@ -1,6 +1,16 @@ //! Complete Air checks82D90358 including bad landing82D90D08. use super::{common, Frame, Mode, Requests, Settings}; use crate::physics::native_arithmetic::dot3; +///82D8FEE8, shared off-board air collision check; no on-board cooldown/landing tests. +pub fn check_collision(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame) { + if mode.check_squash && f.maximum_pose_error > s.air.max_squash { + state.request(18, 0.0); + } else if dot3(f.pose_error, f.pose_error) > s.air.max_displacement * s.air.max_displacement { + state.request(1, 0.0); + } else if common::force(f, s.air.max_contact, s.air.max_arm_contact) { + state.request(0, 0.0); + } +} pub fn check(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, use_com: bool) { if state.mode != 2 { state.mode = 2; diff --git a/crates/skate-core/src/player/wipeout/common.rs b/crates/skate-core/src/player/wipeout/common.rs index 4c2ffb56..d43e41a8 100644 --- a/crates/skate-core/src/player/wipeout/common.rs +++ b/crates/skate-core/src/player/wipeout/common.rs @@ -17,7 +17,7 @@ fn selected_max(a: f32, b: f32) -> f32 { } } ///82BD88A0 reads unweighted regional forces in this exact pair order. -pub fn force(frame: &Frame, body: f32, arms: f32) -> bool { +pub(crate) fn force(frame: &Frame, body: f32, arms: f32) -> bool { let f = frame.regions_force; let a = selected_max(f[0], f[1]); let b = selected_max(f[5], f[4]); diff --git a/crates/skate-core/src/player/wipeout/mod.rs b/crates/skate-core/src/player/wipeout/mod.rs index 1a26f369..a6796beb 100644 --- a/crates/skate-core/src/player/wipeout/mod.rs +++ b/crates/skate-core/src/player/wipeout/mod.rs @@ -6,10 +6,11 @@ mod data; mod ground; mod requests; pub use air::check as check_air; +pub use air::check_collision as check_air_collision; pub use data::{AirSettings, Frame, GroundSettings, Mode, RequestInput, Settings, V}; pub use ground::{check as check_ground, check_animation as check_ground_animation, check_plant}; pub use requests::Requests; -pub use common::force as regional_force; +pub(crate) use common::force as regional_force; #[cfg(test)] mod tests; diff --git a/crates/skate-core/src/riding/grounded/manual_entry.rs b/crates/skate-core/src/riding/grounded/manual_entry.rs index 1fe3f48e..d7443bb5 100644 --- a/crates/skate-core/src/riding/grounded/manual_entry.rs +++ b/crates/skate-core/src/riding/grounded/manual_entry.rs @@ -1,5 +1,6 @@ //! Ground's manual entry slice82D376F4..7750 and velocity helper82D37960. -//! The complete Ground Enter function and native projection are not supplied. +//! Original-image bytes confirm the complete entry-helper branch/write order. +//! The game adapter supplies live board bodies and the shared dot calculation. use crate::physics::manual::state::{ManualEntryContinuation, ManualState}; #[derive(Clone, Copy, Debug)] @@ -24,12 +25,12 @@ pub trait ManualGroundBodies { fn set_linear_velocity(&mut self, part: usize, velocity: [f32; 4]); } -/// Required numerical dependency with no production or fallback implementation. +/// Three-component projection supplied by the host's shared numerical adapter. pub trait ManualGroundProjection { type Error; /// Native three-component projection at82D3799C (and six repeated sites). - /// W does not contribute. Preserve native accumulation/rounding when this - /// operation is recovered; generic host dot arithmetic is not a substitute. + /// W does not contribute. Independent hardware numerical parity of the + /// shared dot implementation is separate from this helper's control flow. fn normal_speed( &mut self, ground_normal: [f32; 4], diff --git a/crates/skate-core/tests/air_constraint_lifecycle.rs b/crates/skate-core/tests/air_constraint_lifecycle.rs new file mode 100644 index 00000000..e0fec203 --- /dev/null +++ b/crates/skate-core/tests/air_constraint_lifecycle.rs @@ -0,0 +1,57 @@ +//! Source-derived lifecycle checks, not a replay or hardware parity fixture. +use skate_core::{ + animation::foot_ik::status::{self, BlendSettings, LimbStatus, Mode}, + physics::hook_drive::HookDriveState, +}; + +#[test] +fn landing_releases_both_deck_drives_and_second_air_entry_is_angular_only() { + let mut drive = HookDriveState::initial(); + let mut animated = 0; + // Start with both channels armed, so stale linear strength is observable. + drive.enable_animation_soft(&mut animated); + assert_ne!(drive.dynamics[2], 0); + assert_ne!(drive.dynamics[6], 0); + let frames = drive.frames; + + // Ground Enter82D375D0..5FC clears both channels, retaining the anchors. + drive.disable_animation(&mut animated); + assert_eq!(animated, 0); + assert_eq!(drive.dynamics, [0, 0, 0, 2, 0, 0, 0, 2]); + assert_eq!(drive.frames, frames); + + // PhysicsAir Enter82D343C8 ->82C05658 must not revive linear following. + drive.enable_angular_only(&mut animated); + assert_eq!(animated, 1); + assert_eq!(&drive.dynamics[..4], &[0, 0, 0, 2]); + assert_ne!(drive.dynamics[6], 0); + assert_eq!(drive.dynamics[7], 1); + assert_eq!(drive.frames, frames); +} + +#[test] +fn released_grab_hand_waits_for_geometric_blend_before_disabling() { + let mut limbs = [LimbStatus::default(); 4]; + limbs[2].mode = Mode::OnDeck; + limbs[2].board_blend = 0.5; + limbs[2].part_position = [4.0, 0.0, 0.0, 0.0]; + // Synthetic dimensions/steps expose82BEDCF8 and82BEEC00's ordering; + // these values are not proposed stock settings or a release-time tuning. + let settings = BlendSettings { + hand_inner_padding: [0.25; 4], + hand_outer_padding: [0.25; 4], + external_blend_step: 0.25, + board_blend_step: 0.25, + }; + for remaining in [0.25, 0.0] { + status::update_modes(&mut limbs, true, 0); + assert_eq!(limbs[2].mode, Mode::OnDeck); + status::update_blends(&mut limbs, 0.5, 1.0, 0, &settings); + assert_eq!(limbs[2].board_blend, remaining); + } + status::update_modes(&mut limbs, true, 0); + assert_eq!(limbs[2].mode, Mode::Disabled); + assert_eq!(limbs[2].external_blend, 0.0); + assert_eq!(limbs[0].mode, Mode::OnDeck); + assert_eq!(limbs[1].mode, Mode::OnDeck); +} diff --git a/crates/skate-core/tests/board_toolkit_speed.rs b/crates/skate-core/tests/board_toolkit_speed.rs new file mode 100644 index 00000000..bdcb81fa --- /dev/null +++ b/crates/skate-core/tests/board_toolkit_speed.rs @@ -0,0 +1,12 @@ +use skate_core::physics::{board_toolkit::BoardToolkit, skeleton_animation_record::IDENTITY}; + +#[test] +fn toolkit_absolute_speed_preserves_original_ordered_sign_multiply() { + let up = [0.0, 1.0, 0.0, 0.0]; + //82C01718..744 compares strictly >0 before multiplying by +/-1. + //Positive zero therefore produces negative zero; fabs is not equivalent. + for (input, expected) in [(3.0_f32, 3.0_f32), (-3.0, 3.0), (0.0, -0.0), (-0.0, 0.0)] { + let toolkit = BoardToolkit::calculate(IDENTITY, &[1.0; 7], 0, input, up, up); + assert_eq!(toolkit.absolute_speed.to_bits(), expected.to_bits()); + } +} diff --git a/crates/skate-core/tests/riding_manual_physical.rs b/crates/skate-core/tests/riding_manual_physical.rs new file mode 100644 index 00000000..12591e6c --- /dev/null +++ b/crates/skate-core/tests/riding_manual_physical.rs @@ -0,0 +1,294 @@ +//! Independent physical-boundary checks, not a stock gameplay/reference replay. +//! Unlike the scalar fixtures, these use production signed-angle/projection, +//! real BoardRuntime bodies and the deck's actual force/torque accumulators. +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board_runtime::{BoardMotion, BoardRuntime}, + deck_angular_correction::apply_angular_displacement, + drive_frames::RetailAffineTransform, + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::{ + controller::{self, ManualAngleMeasurement, ManualInput}, + settings::{ManualGains, ManualMode, ManualSettings}, + state::ManualState, + }, + point_force::RetailForceAccumulator, + rigid_body::{ + RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame, + RetailSimulationStep, + }, + }, + point_graph::PointGraph, + riding::{ + collision_response::signed_angle, + ground_correction_math::dot_product, + grounded::{ + manual_entry::{self, ManualGroundBodies, ManualGroundInput, ManualGroundProjection}, + propulsion::{GroundPropulsion, PropulsionSubmission}, + }, + push::PushAcceleration, + }, +}; +use std::convert::Infallible; + +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn state() -> ManualState { + ManualState { + filtered_angle_error: 0.0, + target_angle: 0.0, + measured_angle: 0.0, + angular_correction: 0.0, + elapsed: 1.0, + } +} +fn settings() -> ManualSettings { + ManualSettings { + noise_vs_speed: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.; 8], + }, + torque_scale_without_contact: 0.25, + torque_bleed_without_contact: 0.5, + start_torque_scale: 0.0, + procedural_noise_scale: 0.0, + procedural_noise_frequency: 0.0, + powerslide: ManualGains { + proportional: 1.0, + integral: 0.0, + derivative: 0.0, + }, + manual: ManualGains { + proportional: 1.0, + integral: 0.0, + derivative: 0.0, + }, + maximum_tilt_degrees: 10.0, + maximum_angle_error: 1.0, + derivative_limit: 1.0, + brake_tilt_degrees: 0.0, + animation_noise_scale: 0.0, + } +} +fn mode(enabled: bool) -> ManualMode { + ManualMode { + correction_angular_speed_threshold: 2.0, + corrective_force_enabled: enabled, + } +} +fn input() -> ManualInput { + ManualInput { + balance: 0.4, + flipped_controls: 1.0, + procedural_noise_time: 0.0, + absolute_speed: 3.0, + animation_noise: 0.0, + timestep: 1.0 / 60.0, + powersliding: false, + braking: false, + positive_balance_contact: true, + negative_balance_contact: true, + reversed_point_selection: false, + //A yawed reference ensures no assertion relies on world X/Z aliases. + reference_x: [0., 0., -1., 0.], + reference_z: [1., 0., 0., 0.], + deck_z: [1., 0., 0., 0.], + velocity_frame_z: [1., 0., 0., 0.], + angular_velocity_world: [-3., 0., 0., 0.], + correction_point_7888: [0., -0.1, 0.3, 0.], + correction_point_7952: [0., -0.1, -0.3, 0.], + } +} +struct Geometry; +impl ManualAngleMeasurement for Geometry { + type Error = Infallible; + fn angle_between( + &mut self, + a: [f32; 4], + b: [f32; 4], + axis: [f32; 4], + ) -> Result { + Ok(signed_angle(xyz(a), xyz(b), xyz(axis))) + } +} +fn board() -> BoardRuntime { + //Deliberate unit-mass/inertia test inputs; not stock construction claims. + let mass = RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(1., 1., 1.), + inverse_mass: 1.0, + spherical: 0.0, + maximum_linear_velocity: 100.0, + maximum_angular_velocity: 100.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + }; + BoardRuntime::new( + [mass; 7], + [RetailAffineTransform::IDENTITY; 7], + RetailAffineTransform::IDENTITY, + RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), + BoardMotion::Active, + ) +} + +#[test] +fn measured_deck_error_produces_restoring_torque_on_both_sides_of_both_targets() { + for balance in [-0.4_f32, 0.4] { + for control_sign in [-1.0_f32, 1.0] { + for offset in [-0.08_f32, 0.08] { + let mut i = input(); + i.balance = balance; + i.flipped_controls = control_sign; + let target = + (balance * control_sign).signum() * 0.55 * 10.0 * f32::from_bits(0x3c8e_fa35); + let measured = target + offset; + i.deck_z = [measured.cos(), measured.sin(), 0.0, 0.0]; + let mut controller = state(); + let effect = controller::calculate( + &mut controller, + &settings(), + mode(true), + &i, + &mut Geometry, + ) + .unwrap(); + assert!((controller.measured_angle - measured).abs() < 0.001); + assert!((controller.target_angle - target).abs() < 0.00001); + assert!(dot_product(effect.angular_displacement, i.reference_x) * offset > 0.0); + let mut physical = board(); + let before = physical.bodies()[6].rates; + apply_angular_displacement( + &mut physical.bodies_mut()[6].rates, + xyz(effect.angular_displacement), + ); + let after = physical.bodies()[6].rates; + assert!( + dot_product(lanes(after.torque_acceleration), i.reference_x) * offset > 0.0 + ); + assert_eq!(after.orientation, before.orientation); + assert_eq!(after.position, before.position); + assert_eq!(after.angular_velocity, before.angular_velocity); + assert!(!effect.correction_active); + } + } + } +} + +struct Bodies<'a>(&'a mut BoardRuntime); +impl ManualGroundBodies for Bodies<'_> { + fn linear_velocity(&mut self, part: usize) -> [f32; 4] { + lanes(self.0.bodies()[part].rates.linear_velocity) + } + fn set_linear_velocity(&mut self, part: usize, v: [f32; 4]) { + self.0.bodies_mut()[part].rates.linear_velocity = xyz(v); + } +} +struct Projection; +impl ManualGroundProjection for Projection { + type Error = Infallible; + fn normal_speed(&mut self, n: [f32; 4], v: [f32; 4]) -> Result { + Ok(dot_product(n, v)) + } +} + +#[test] +fn landing_entry_removes_real_normal_velocity_only_from_native_selected_parts() { + for reversed in [false, true] { + for speed in [-3.0_f32, 3.0] { + let mut physical = board(); + let normal = [0.0, 0.6, 0.8, 0.0]; + for body in physical.bodies_mut() { + body.rates.linear_velocity = Vector3::new(2.0, speed * 0.6, speed * 0.8); + body.rates.angular_velocity = Vector3::new(1.0, 2.0, 3.0); + } + let before = *physical.bodies(); + let mut manual = state(); + manual_entry::enter_ground( + &mut manual, + 200, + 0.25, + ManualGroundInput { + balance: 0.4, + ground_normal: normal, + flags_2468: if reversed { 1 << 20 } else { 0 }, + flags_2472: 1 << 27, + }, + &mut Bodies(&mut physical), + &mut Projection, + ) + .unwrap(); + let selected = if reversed { [2, 3, 5, 6] } else { [0, 1, 4, 6] }; + for part in 0..7 { + let after = physical.bodies()[part].rates; + if selected.contains(&part) { + assert!(dot_product(normal, lanes(after.linear_velocity)).abs() < 0.000001); + assert_eq!(after.linear_velocity.x, 2.0); + } else { + assert_eq!(after.linear_velocity, before[part].rates.linear_velocity); + } + assert_eq!(after.angular_velocity, before[part].rates.angular_velocity); + assert_eq!(after.orientation, before[part].rates.orientation); + } + assert_eq!(manual.angular_correction, 0.0); + assert_eq!(manual.elapsed, 0.0); + } + } +} + +#[test] +fn opposing_ground_velocity_selects_tag7_and_reaches_linear_and_torque_accumulators() { + let mut i = input(); + i.braking = true; + let propulsion = GroundPropulsion { + braking: QueuedPointForce::default(), + push: PushAcceleration::default(), + }; + let effect = + controller::calculate(&mut state(), &settings(), mode(true), &i, &mut Geometry).unwrap(); + assert!(effect.correction_active); + assert_eq!(effect.corrective_force_world, [1200.0, -0.0, -0.0, -0.0]); + let mut queue = BoardForceQueue::default(); + assert_eq!( + propulsion.submit(&effect, &mut queue), + PropulsionSubmission::ManualCorrection(true) + ); + assert_eq!(queue.entries().len(), 1); + assert_eq!(queue.entries()[0].tag, 7); + let identity = Basis3 { + columns: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], + }; + let output = queue.apply_to_deck( + RetailForceAccumulator { + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + cool_down: 7, + }, + identity, + 1.0, + identity, + 0.0, + ); + assert_eq!(output.force_acceleration, Vector3::new(1200.0, 0.0, 0.0)); + assert!(output.torque_acceleration.y > 350.0); + assert!(output.torque_acceleration.z > 110.0); + assert_eq!(output.cool_down, 0); + + let disabled = + controller::calculate(&mut state(), &settings(), mode(false), &i, &mut Geometry).unwrap(); + assert!(!disabled.correction_active); + assert!(disabled.opposing_motion_without_correction); + let mut ordinary = BoardForceQueue::default(); + assert!(matches!( + propulsion.submit(&disabled, &mut ordinary), + PropulsionSubmission::BrakingAndPush(_) + )); + assert!(!ordinary.entries().iter().any(|force| force.tag == 7)); +} diff --git a/crates/skate-data/src/input_recording.rs b/crates/skate-data/src/input_recording.rs new file mode 100644 index 00000000..5074906b --- /dev/null +++ b/crates/skate-data/src/input_recording.rs @@ -0,0 +1,93 @@ +//! Standalone recorder JSONL. Capture timestamps are host observations, not +//! original-game simulation frames. Replay holds the latest observed raw state. +use serde::Deserialize; +use skate_core::input::xbox::XboxState; +use std::{io::BufRead, path::Path}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sample { + schema: u32, + pub t_us: u64, + pub sample: u64, + pub connected: bool, + pub packet: Option, + buttons: u16, + triggers: [u8; 2], + left: [i16; 2], + right: [i16; 2], +} +impl Sample { + pub fn raw_state(&self) -> XboxState { + XboxState { + buttons: self.buttons, + triggers: self.triggers, + left: self.left, + right: self.right, + } + } +} + +#[derive(Clone, Debug)] +pub struct Recording { + samples: Vec, +} +impl Recording { + pub fn load(path: &Path) -> Result { + let file = std::fs::File::open(path) + .map_err(|e| format!("Input recording {}: {e}", path.display()))?; + Self::read(std::io::BufReader::new(file)) + } + pub fn read(reader: impl BufRead) -> Result { + let mut samples: Vec = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let line = line.map_err(|e| format!("Input recording line {}: {e}", index + 1))?; + if line.trim().is_empty() { + continue; + } + let sample: Sample = serde_json::from_str(&line) + .map_err(|e| format!("Input recording line {}: {e}", index + 1))?; + if sample.schema != 1 || sample.sample != samples.len() as u64 { + return Err(format!( + "Input recording line {}: unsupported schema or missing/out-of-order sample", + index + 1 + )); + } + if samples + .last() + .is_some_and(|previous| sample.t_us <= previous.t_us) + { + return Err(format!( + "Input recording line {}: timestamps must increase", + index + 1 + )); + } + if sample.connected != sample.packet.is_some() { + return Err(format!( + "Input recording line {}: connection and packet disagree", + index + 1 + )); + } + samples.push(sample); + } + if samples.is_empty() { + return Err("Input recording is empty".into()); + } + Ok(Self { samples }) + } + pub fn samples(&self) -> &[Sample] { + &self.samples + } + pub fn duration_us(&self) -> u64 { + self.samples.last().unwrap().t_us - self.samples[0].t_us + } + /// Zero is the first captured sample. No axis interpolation, invented edges, + /// or rescaling by sample count; the final sample remains until playback ends. + pub fn at_elapsed_us(&self, elapsed: u64) -> &Sample { + let timestamp = self.samples[0].t_us.saturating_add(elapsed); + let next = self + .samples + .partition_point(|sample| sample.t_us <= timestamp); + &self.samples[next - 1] + } +} diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index d6674afe..7677f014 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -11,6 +11,7 @@ pub mod scoring; mod scoring_fields; pub mod attrib_hash; pub mod input_config; +pub mod input_recording; pub mod gesture_patterns; pub mod physics_skeleton; mod sha256; diff --git a/crates/skate-data/tests/input_recording.rs b/crates/skate-data/tests/input_recording.rs new file mode 100644 index 00000000..5fb45020 --- /dev/null +++ b/crates/skate-data/tests/input_recording.rs @@ -0,0 +1,31 @@ +use skate_data::input_recording::Recording; +fn line(sample: u64, t_us: u64, connected: bool, right_y: i16) -> String { + format!( + "{{\"schema\":1,\"sample\":{sample},\"t_us\":{t_us},\"connected\":{connected},\"packet\":{},\"buttons\":0,\"triggers\":[0,0],\"left\":[0,0],\"right\":[0,{right_y}]}}\n", + if connected { "5" } else { "null" } + ) +} +#[test] +fn irregular_timing_keeps_edges_axes_and_disconnects() { + let text = + line(0, 18000, true, -32768) + &line(1, 60000, true, 32767) + &line(2, 65000, false, 0); + let recording = Recording::read(text.as_bytes()).unwrap(); + assert_eq!(recording.duration_us(), 47000); + assert_eq!(recording.at_elapsed_us(41999).raw_state().right[1], -32768); + assert_eq!(recording.at_elapsed_us(42000).raw_state().right[1], 32767); + assert!(!recording.at_elapsed_us(47000).connected); + assert!(!recording.at_elapsed_us(u64::MAX).connected); +} +#[test] +fn truncated_misordered_and_unknown_capture_formats_fail_explicitly() { + for text in [ + String::new(), + line(0, 10, true, 0) + "{", + line(1, 10, true, 0), + line(0, 10, true, 0) + &line(1, 9, true, 0), + line(0, 10, true, 0).replace("\"schema\":1", "\"schema\":2"), + line(0, 10, true, 0).replace("\"packet\":5", "\"packet\":null"), + ] { + assert!(Recording::read(text.as_bytes()).is_err(), "accepted {text}"); + } +} diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 84307f5a..5c2df903 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -2,7 +2,7 @@ //! The GLB supplies the mesh, skin weights and hierarchy; it does not play a //! separate idle clip or own the skater's pose. use crate::{app::FrameSet, assets::AssetStatus, physics::SkaterRuntime, world::PlayerRoot}; -use bevy::{mesh::skinning::SkinnedMesh, prelude::*}; +use bevy::{camera::visibility::NoFrustumCulling, mesh::skinning::SkinnedMesh, prelude::*}; use skate_core::animation::output::NativeMatrix; #[derive(Resource, Default)] @@ -88,9 +88,25 @@ pub(crate) struct AnimationPlugin; impl Plugin for AnimationPlugin { fn build(&self, app: &mut App) { app.init_resource::() - .add_systems(Update, (bind, present).chain().in_set(FrameSet::Animation)); + .add_systems(Update, (disable_skater_culling, bind, present).chain().in_set(FrameSet::Animation)); } } +fn disable_skater_culling( + mut commands: Commands, + skins: Query, Without)>, + parents: Query<&ChildOf>, + roots: Query>, +) { + // GLB primitive bounds describe the bind pose, not the animated skin. + // Apply to every skater mesh (not just the first skin used to bind bones). + // World meshes retain their normal culling behavior. + for entity in &skins { + if parents.iter_ancestors(entity).any(|e| roots.contains(e)) { + commands.entity(entity).insert(NoFrustumCulling); + } + } +} + fn bind( status: Res, skater: Res, diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs index 95aaa40c..433a67db 100644 --- a/crates/skate-game/src/graph_host/action.rs +++ b/crates/skate-game/src/graph_host/action.rs @@ -93,8 +93,7 @@ impl ActionHost { } } } - // Keep lazy unsupported-operation errors: optional graph branches - // are not a reason to prevent loading the working game. + // Preserve lazy diagnostics for optional unsupported graph branches. super::condition_nodes::bind_current_states(graph, &mut instances); let mut host = Self::new(instances, graph.runtime.operations.clone()); host.body_flip_settings = Some(body_flip_signal::Settings { diff --git a/crates/skate-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs index 8795056a..e348f479 100644 --- a/crates/skate-game/src/graph_host/action_conditions.rs +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -40,9 +40,11 @@ impl ActionCondition { target: None, numeric: super::condition_nodes::numeric(a), }, - "IsGrabbingObject" | "IsHandPlanting" | "IsFootPlanting" => Self::Physical(GameplayCondition::parse(a)?), - "IsTricking" => Self::Tricking, + "IsGrabbingObject" | "IsHandPlanting" | "IsFootPlanting" => { + Self::Physical(GameplayCondition::parse(a)?) + } "IsDroppingIn" => Self::DroppingIn, + "IsTricking" => Self::Tricking, "IsInLocomotion" => Self::InLocomotion, "DisableDismount" => Self::DisableDismount, "TimeToLand" => Self::TimeToLand(super::condition_nodes::numeric(a)), @@ -51,15 +53,6 @@ impl ActionCondition { } pub fn evaluate(&self, host: &ActionHost, frame: &Frame) -> Result { Ok(match self { - Self::DroppingIn => host.dropping_in - .ok_or("IsDroppingIn requires physical grind publication")?, - // Same native physical leaf used by MotionGraph::TimeToLand. - Self::TimeToLand(numeric) => numeric.matches( - host.gameplay_conditions - .as_ref() - .ok_or("TimeToLand requires physical condition publication")? - .time_to_land, - ), Self::Gesture(group) => group.has_intent(|name| host.action_intents.contains_key(name)), Self::AnimationAttribute { name, @@ -82,19 +75,18 @@ impl ActionCondition { ) .ok_or("Condition is not a physical-only original leaf")?, //82BA56B8 uses specific MotionGraph virtual100=8258F8D8 bit27. + Self::DroppingIn => host.dropping_in.ok_or("IsDroppingIn requires completed grind output")?, Self::Tricking => host .is_tricking .ok_or("IsTricking requires actual MotionGraph flag27")?, //Factory82BC3F68 installs8231ED5C; virtual48=8274CA90 is li r3,0;blr. Self::InLocomotion => false, - Self::DisableDismount => { - //82BA5368:822F8C9C > Ground80.Y OR Skeleton598. - let p = host - .condition_inputs - .push_brake - .as_ref() - .ok_or("DisableDismount requires actual ground axis and skeleton598")?; - f32::from_bits(0x3f23d70a) > p.ground_axis_y || p.skeleton_disables_push_brake + Self::DisableDismount => super::motion_dismount::Condition + .evaluate(host.condition_inputs.push_brake.as_ref())?, + Self::TimeToLand(numeric) => { + let p = host.gameplay_conditions.as_ref() + .ok_or("ActionGraph TimeToLand requires actual physical condition outputs")?; + p.time_to_land_valid && numeric.matches(p.time_to_land) } }) } diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs index d4b5ea90..e2fd209c 100644 --- a/crates/skate-game/src/graph_host/mod.rs +++ b/crates/skate-game/src/graph_host/mod.rs @@ -1,51 +1,59 @@ //! Production graph host building blocks. Root owns schedule and registration. +// Offboard graph leaves remain parsed/source-backed but are not registered as +// live state owners until the corresponding Biped services are integrated. +#![allow(dead_code)] pub mod action; +pub mod motion_manual; +pub(crate) mod outputs; mod action_conditions; pub(crate) mod action_nodes; mod condition_nodes; pub(crate) use condition_nodes::numeric as parse_numeric_condition; mod crouching_settings; pub mod motion; -pub(crate) mod motion_grind; mod motion_air_leg; mod motion_animation; -#[path = "motion_offboard/cadence.rs"] -pub(crate) mod motion_cadence; +pub(crate) mod motion_grind; mod motion_channels; pub(crate) mod motion_character_gesture; mod motion_conditions; +#[path = "motion_offboard/dismount.rs"] +mod motion_dismount; +#[path = "motion_offboard/cadence.rs"] +pub(crate) mod motion_offboard_cadence; +#[path = "motion_offboard/runout.rs"] +pub(crate) mod motion_runout; +#[path = "motion_offboard/toggle_board.rs"] +pub(crate) mod motion_toggle_board; pub(crate) mod motion_gameplay_conditions; -mod motion_ground_slope; +pub(crate) mod motion_ground_slope; pub(crate) mod motion_hand_services; mod motion_hooks; +mod motion_hippy_jump; pub(crate) mod motion_intent_filter; mod motion_kickturn; pub(crate) mod motion_landing; mod motion_landing_execute; pub(crate) mod motion_native; mod motion_nodes; -#[path = "motion_offboard/air.rs"] -pub(crate) mod motion_offboard_air; +mod motion_tricks; +mod motion_scoring_trick; +mod motion_bump; #[path = "motion_offboard/push_off.rs"] pub(crate) mod motion_push_off; #[path = "motion_offboard/reset.rs"] pub(crate) mod motion_reset; mod motion_riding; -mod motion_manual; pub(crate) mod motion_riding_conditions; -pub(crate) mod motion_runout; pub(crate) mod motion_shove; mod motion_sliding; pub(crate) mod motion_spin; -pub(crate) mod motion_toggle_board; -mod motion_tricks; #[path = "motion_offboard/twist_lean.rs"] pub(crate) mod motion_twist_lean; pub(crate) mod motion_wipeout; +pub(crate) mod motion_stock_gameplay; +pub(crate) mod motion_stock_conditions; mod pumping_settings; pub mod pushing; pub mod pushing_settings; mod turning_settings; - -pub(crate) mod outputs; -pub(crate) mod motion_stock_gameplay; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index b99a238c..1d93be0e 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -1,20 +1,28 @@ -#[path = "motion_stock_execute.rs"] -mod stock_execution; +#[path = "motion_toggle_execute.rs"] +mod toggle_execution; #[path = "motion_air_execute.rs"] mod air_execution; #[path = "motion_execute.rs"] mod execution; +#[path = "motion_execute_leaf.rs"] +mod leaf_execution; +#[path = "motion_score_execute.rs"] +mod score_execution; #[path = "motion_filter_execute.rs"] mod filter_execution; #[path = "motion_sliding_execute.rs"] mod slide_execution; #[path = "motion_wipeout_execute.rs"] mod wipeout_execution; +#[path = "motion_offboard/body_tweak.rs"] +pub(crate) mod body_tweak; +#[path = "motion_offboard/match_air_time.rs"] +pub(crate) mod match_air_time; // Persistent host called by the production stock MotionGraph controller. pub use super::motion_animation::MotionAnimation; use super::{ motion_nodes::{MotionFactory, MotionOperation}, - pushing::{PushContext, PushInstance, PushOperation}, + pushing::{PushContext, PushInstance}, pushing_settings::PushingSettings, }; use crate::graph_runtime::{LoadedGraph, OperationRemap}; @@ -35,6 +43,7 @@ use skate_core::{ }; use skate_data::collections::Collections; use std::collections::BTreeMap; +use super::outputs::{ActionControls, GraphCapabilityReport, GraphDiagnostics, GraphEffects, MotionGraphInput, TurningOutput}; #[derive(Clone, Copy, Debug)] pub struct MotionPhysical { @@ -45,125 +54,27 @@ pub struct MotionPhysical { pub is_switch: bool, pub foot_frame: Option, } -enum Instance { - Grind(super::motion_grind::State), - OffboardAir(super::motion_offboard_air::State), - ToggleBoard(super::motion_toggle_board::ToggleBoard), - Cadence(super::motion_cadence::MatchCadence), - Runout(Option), - Trick(i32), - JumpInto(bool), - Stateless, - Manual(skate_core::animation::manual::State), - IntentFilter(skate_core::animation::intent_filter::State), - Landing(super::motion_landing::State), - Wipeout(super::motion_wipeout::State), - TwistLean(skate_core::player::offboard::twist_lean::State), - Play(PlayAnimationInstance), - Push(PushInstance), - Turning(set_turning::State), - Crouching(Option), - BodyTilt(skate_core::animation::body_tilt::State), - RidingFakie(skate_core::animation::riding_fakie::State), - Pumping(skate_core::animation::pumping_channel::State), - FakieHead(super::motion_channels::FakieHead), - LandingData(super::motion_native::LandingData), - Sliding(super::motion_sliding::State), - SlideDeceleration(f32), - KickTurn(skate_core::animation::kickturn::State), - BodySpin(super::motion_spin::State), - AirLeg(skate_core::animation::air_leg_extension::State), - CharacterGesture(super::motion_character_gesture::CharacterGesture), - Shove(super::motion_shove::ShoveState), -} -impl Instance { - fn new(operation: &MotionOperation) -> Self { - match operation { - MotionOperation::JumpInto(_) => Self::JumpInto(true), - MotionOperation::Grind(_) => Self::Grind(Default::default()), - MotionOperation::OffboardAir(_) => Self::OffboardAir(Default::default()), - MotionOperation::Cadence(_) => Self::Cadence(Default::default()), - MotionOperation::AddRunoutAttribs => Self::Runout(None), - MotionOperation::Trick(_) => Self::Trick(0), - MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), - MotionOperation::Landing(_) => Self::Landing(Default::default()), - MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()), - MotionOperation::TwistLean(_) => Self::TwistLean(Default::default()), - MotionOperation::Play(_) => Self::Play(PlayAnimationInstance::default()), - MotionOperation::Push(operation) => Self::Push(PushInstance::new(operation.clone())), - MotionOperation::SetTurning(_) => Self::Turning(set_turning::State { - elapsed: 0.0, - smoothed: 0.0, - mode: 2, - }), - MotionOperation::SetManualAngle => Self::Manual(Default::default()), - MotionOperation::Crouching(_) => Self::Crouching(None), - MotionOperation::SettingBodyTilt(_) => { - Self::BodyTilt(skate_core::animation::body_tilt::State::default()) - } - MotionOperation::UpdateRidingFakie(_) => { - Self::RidingFakie(skate_core::animation::riding_fakie::State::default()) - } - MotionOperation::Pumping => { - Self::Pumping(skate_core::animation::pumping_channel::State::default()) - } - MotionOperation::FakieHeadChannel => { - Self::FakieHead(super::motion_channels::FakieHead::default()) - } - MotionOperation::Native(super::motion_native::Operation::StoreLandingData) => { - Self::LandingData(super::motion_native::LandingData::default()) - } - MotionOperation::Slide(super::motion_sliding::Operation::Update) => { - Self::Sliding(super::motion_sliding::State::default()) - } - MotionOperation::Slide(super::motion_sliding::Operation::Deceleration) => { - Self::SlideDeceleration(0.0) - } - MotionOperation::KickTurn(super::motion_kickturn::Operation::Steering(_)) => { - Self::KickTurn(skate_core::animation::kickturn::State::new()) - } - MotionOperation::BodySpin => Self::BodySpin(super::motion_spin::State::default()), - MotionOperation::AirLeg(_) => Self::AirLeg(Default::default()), - MotionOperation::CharacterGesture => { - Self::CharacterGesture(super::motion_character_gesture::CharacterGesture::new()) - } - MotionOperation::Shove(_) => Self::Shove(super::motion_shove::ShoveState::new()), - MotionOperation::ToggleBoard => Self::ToggleBoard(Default::default()), - MotionOperation::ResetAnimation(_) => Self::Stateless, - _ => Self::Stateless, - } - } -} -use super::outputs::{ActionControls, GraphDiagnostics, GraphEffects, MotionGraphInput, TurningOutput}; - +#[path = "motion_instances.rs"] +mod instances; +use instances::Instance; pub struct MotionHost { - pub action_controls: ActionControls, - pub turning_output: TurningOutput, - pub graph_effects: GraphEffects, - pub moving_objects: super::motion_stock_gameplay::MovingObjectRegistry, - pub grind_physical: super::motion_grind::Physical, - grind_settings: super::motion_grind::Settings, - pub offboard_output: skate_core::player::input_phase::OffBoardOutputFields, - pub toggle_board_physical: super::motion_toggle_board::Physical, - pub disable_dismount: bool, - pub offboard_phase: f32, - pub offboard_locomotion: u32, - pub offboard_slope: u32, - pub offboard_support: [f32; 3], - pub offboard_ground_thin: bool, - pub phase_write: Option, pub animation: MotionAnimation, pub playback_context: PlaybackContext, pub condition_inputs: ConditionInputs, pub gameplay_conditions: Option, + pub manual_exit: Option, pub riding_conditions: Option, pub condition_random: super::motion_riding_conditions::MotionRandom, pub native_physical: Option, - pub runout_physical: Option, pub prelanding_physical: Option, pub air_leg_physical: Option, pub gesture_physical: Option, pub gesture_publication: Option, + pub toggle_board_physical: Option, + pub runout_physical: Option, + pub grind_physical: Option, + pub grind_conditions: Option, + pub(super) grind_settings: super::motion_grind::Settings, pub shove_physical: Option, pub hand_services: super::motion_hand_services::HandServices, pub slide_latch: set_turning::SlideLatch, @@ -172,9 +83,22 @@ pub struct MotionHost { pub score_packet: super::motion_native::ScorePacket, pub(super) trick_requests: super::motion_tricks::Requests, pub(super) trick_height_settings: (bool, bool), - pub action_intents: IntentMap, + pub action_controls: ActionControls, + pub(crate) action_intents: IntentMap, + /// Typed turning values transported from the ActionGraph boundary. + pub turning_output: TurningOutput, + pub graph_effects: GraphEffects, pub time_tags: Option>, pub physical: Option, + /// Completed Biped/OffBoard publications. These remain absent until the + /// corresponding physical owner is integrated; graph leaves must not + /// invent replacement values. + pub offboard_cadence_phase: Option, + pub offboard_locomotion_state: Option, + pub ground_slope_type: Option, + pub biped_ground_thin: Option, + pub animation_phase: f32, + pub moving_objects: super::motion_stock_gameplay::MovingObjectRegistry, pub push_state: Option, pub applying_body_tilt: bool, pub crouching_physical: Option, @@ -183,14 +107,16 @@ pub struct MotionHost { /// PhysOutAnimation158/157, used directly by IsRidingGoofy82BA5AA8. pub physical_stance: Option<(bool, bool)>, pub flags: super::motion_landing::Flags, + hippy_jump: super::motion_hippy_jump::Settings, pub landing_physical: Option, pub wipeout_physical: Option, pub wipeout_controls: super::motion_wipeout::Controls, wipeout_settings: super::motion_wipeout::Settings, ///Native PhysOutGround pumping acceleration268. pub pumping_acceleration: Option, + /// Completed PhysOutAnimation112 acceleration before bump conditioning. pub bump_acceleration: Option<[f32; 4]>, - bump_settings: skate_core::animation::bump::Settings, + bump_settings: super::motion_bump::Settings, pub allow_pumping: bool, pub riding: super::motion_riding::RidingState, pub errors: GraphDiagnostics, @@ -201,14 +127,13 @@ pub struct MotionHost { pushing: PushingSettings, turning: set_turning::Settings, crouching: skate_core::animation::crouching::Settings, - manual: skate_core::animation::manual::Settings, - pub manual_exit: Option, body_tilt: skate_core::animation::body_tilt::Settings, pumping: skate_core::animation::pumping_channel::Settings, sliding: super::motion_sliding::Settings, pub(super) spin: super::motion_spin::Settings, air_leg: skate_core::animation::air_leg_extension::Settings, kickturn: skate_core::animation::kickturn::Settings, + manual: skate_core::animation::manual::Settings, next_instance: u32, } impl MotionHost { @@ -242,6 +167,35 @@ impl MotionHost { .iter() .map(|&id| Instance::new(&operations[id])) .collect(); + let mut capabilities = GraphCapabilityReport { + supported_operations: operations + .iter() + .filter(|operation| !matches!(operation, MotionOperation::Unsupported { .. })) + .count(), + unsupported_operations: operations + .iter() + .filter_map(|operation| match operation { + MotionOperation::Unsupported { kind, name } => { + Some(format!("{:?} `{name}`", kind)) + } + _ => None, + }) + .collect(), + ..GraphCapabilityReport::default() + }; + for &operation in &graph.runtime.operations.conditions { + if let Some(MotionOperation::Unsupported { kind, name }) = operations.get(operation) { + capabilities + .unsupported_conditions + .push(format!("{kind:?} `{name}`")); + } + } + for &operation in &graph.runtime.operations.hooks { + if let Some(MotionOperation::Unsupported { kind, name }) = operations.get(operation) { + capabilities.unsupported_hooks.push(format!("{kind:?} `{name}`")); + } + } + // Preserve lazy diagnostics for optional unsupported graph branches. //8258F488 and final reset825953B0 explicitly zero the complete push //state; reset clears manualing and body-tilt flag bits too. let zero = skate_core::riding::push_animation::PushBlendParameters { @@ -258,46 +212,42 @@ impl MotionHost { }); let pushing = PushingSettings::load(data, &mut metadata)?; Ok(Self { - grind_physical: Default::default(), - grind_settings: super::motion_grind::Settings::load(data)?, - toggle_board_physical: Default::default(), - disable_dismount: false, - offboard_output: Default::default(), - offboard_phase: 0., - offboard_locomotion: 0, - offboard_slope: 0, - offboard_support: [0.; 3], - offboard_ground_thin: false, - phase_write: None, animation: MotionAnimation::from_metadata(metadata), playback_context, condition_inputs: ConditionInputs::default(), gameplay_conditions: None, + manual_exit: None, riding_conditions: None, condition_random: super::motion_riding_conditions::MotionRandom::new(), native_physical: None, - runout_physical: None, prelanding_physical: None, air_leg_physical: None, gesture_physical: None, gesture_publication: None, + toggle_board_physical: None, + runout_physical: None, + grind_physical: None, + grind_conditions: None, + grind_settings: super::motion_grind::Settings::load(data)?, shove_physical: None, hand_services: Default::default(), slide_latch: set_turning::SlideLatch::default(), is_power_sliding: false, score_packet: super::motion_native::ScorePacket::default(), trick_requests: Default::default(), - trick_height_settings: ( - data.boolean("anim_motion", "jumping", "use_gesture_speed")?, - data.boolean("anim_motion", "jumping", "clamp_gesture_to_antic")?, - ), - action_intents: IntentMap::new(), + trick_height_settings: (true, true), action_controls: ActionControls::default(), + action_intents: IntentMap::new(), turning_output: TurningOutput::default(), graph_effects: GraphEffects::default(), - moving_objects: Default::default(), time_tags: None, physical: None, + offboard_cadence_phase: None, + offboard_locomotion_state: None, + ground_slope_type: None, + biped_ground_thin: None, + animation_phase: 0.0, + moving_objects: Default::default(), push_state, applying_body_tilt: false, crouching_physical: None, @@ -311,12 +261,7 @@ impl MotionHost { wipeout_settings: super::motion_wipeout::Settings::load(data)?, pumping_acceleration: None, bump_acceleration: None, - bump_settings: skate_core::animation::bump::Settings { - scale_x_acc: data.float("anim_motion", "bumps", "scale_x_acc")?, - min_bump_mag: data.float("anim_motion", "bumps", "min_bump_mag")?, - min_bump_blend_value: data.float("anim_motion", "bumps", "min_bump_blend_value")?, - max_bump_mag: data.float("anim_motion", "bumps", "max_bump_mag")?, - }, + bump_settings: super::motion_bump::Settings::load(data)?, //8258F488 seeds bit24, and reset825953B0 preserves that bit. allow_pumping: true, riding: super::motion_riding::RidingState::new(), @@ -328,17 +273,18 @@ impl MotionHost { pushing, turning: super::turning_settings::load(data)?, crouching: super::crouching_settings::load(data)?, - manual: super::motion_manual::settings(data)?, - manual_exit: None, body_tilt: super::motion_riding::body_tilt_settings(data)?, pumping: super::pumping_settings::load(data)?, sliding: super::motion_sliding::Settings::load(data)?, spin: super::motion_spin::Settings::load(data)?, air_leg: super::motion_air_leg::load(data)?, kickturn: super::motion_kickturn::load_settings(data)?, + manual: super::motion_manual::settings(data)?, + hippy_jump: super::motion_hippy_jump::Settings::load(data)?, next_instance: 1, }) } + pub fn accept_action_graph(&mut self, input: MotionGraphInput) { let MotionGraphInput { tick, action } = input; debug_assert_eq!(tick, action.tick); @@ -409,6 +355,7 @@ impl Host for MotionHost { .cloned() { match operation { + super::motion_hooks::MotionHook::GrabSlide { right } => { self.slide_latch.grab(right) } @@ -456,9 +403,5 @@ mod tests; mod slide_tests; #[cfg(test)] -#[path = "tests/motion_bump.rs"] -mod bump_tests; - -#[cfg(test)] -#[path = "tests/motion_tricks.rs"] -mod trick_tests; +#[path = "tests/motion_jump_into.rs"] +mod jump_into_tests; diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs index 5f5fce87..9051aadf 100644 --- a/crates/skate-game/src/graph_host/motion_animation.rs +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -1,6 +1,7 @@ //! Stock tree ownership and persistent playback used directly by MotionHost. mod selection_space_host; mod tree_builder; +mod stock_clip_query; use skate_core::animation::posture::{PendingPosture, PosturePose}; use skate_core::animation::{ clip_clock::AdvanceResult, @@ -16,11 +17,9 @@ use skate_core::animation::{ use skate_core::graph::intents::IntentMap; use skate_data::animation_metadata::{AnimationMetadata, TreeMetadata}; use tree_builder::build; - use super::outputs::ActionGraphOutput; pub struct MotionAnimation { - pub grab_type: Option, metadata: AnimationMetadata, current: Option, pub channels: super::motion_channels::MotionChannels, @@ -28,6 +27,7 @@ pub struct MotionAnimation { pub motion_intents: IntentMap, pub filtered_intents: IntentMap, pub motion_attributes: Vec, + pub grab_type: Option, pub construction_values: Vec<(AttributeName, AttributeName)>, pub posture: PendingPosture, pub posture_bank_valid: bool, @@ -45,33 +45,28 @@ pub struct MotionAnimation { property: AdvanceResult, } impl MotionAnimation { + #[cfg(test)] + pub fn seek_current_fraction(&mut self, fraction: f32) { + if let Some(tree) = &mut self.current { tree.set_time(tree.length() * fraction); } + } + + pub(crate) fn metadata(&self) -> &AnimationMetadata { + &self.metadata + } + + #[cfg(test)] + pub(super) fn pending_parameter(&self, name: AttributeName) -> Option { + self.settable.entries().iter().find(|a| a.name == name).map(|a| a.value) + } pub fn accept_action_graph(&mut self, output: ActionGraphOutput) { output.motion_effects.apply_to(&mut self.motion_intents); } - /// TU3 JumpInto Update82BACDC0: query the live tree with mask31, - /// then seek to the attribute's begin time (not its scalar payload). - pub fn jump_into(&mut self, name: AttributeName) -> Result<(), String> { - if let Some(tree) = &mut self.current { - if let Some(marker) = tree.attribute(name, 31)? { - tree.set_time(marker.begin_time); - } - } - Ok(()) - } - pub fn seek_current_fraction(&mut self, fraction: f32) { - if let Some(tree) = &mut self.current { - tree.set_time(tree.length() * fraction); - } - } - pub(crate) fn metadata(&self) -> &AnimationMetadata { - &self.metadata - } pub fn reset_from_stock(&mut self) { self.current = None; self.current_name = None; - self.grab_type = None; self.channels.reset_from_stock(); + self.grab_type = None; self.motion_intents.clear(); self.filtered_intents.clear(); self.motion_attributes.clear(); @@ -98,10 +93,10 @@ impl MotionAnimation { current: None, channels: super::motion_channels::MotionChannels::default(), current_name: None, - grab_type: None, motion_intents: IntentMap::new(), filtered_intents: IntentMap::new(), motion_attributes: Vec::new(), + grab_type: None, construction_values: Vec::new(), posture: PendingPosture::default(), posture_bank_valid: false, @@ -145,23 +140,6 @@ impl MotionAnimation { ); Ok(()) } - ///82BB0A30 samples the actual selected blend tree at normalized endpoints. - ///Work on a clone so endpoint probing cannot leak into the current pose. - pub fn attribute_endpoints(&self, name: AttributeName) -> Result<[f32;2],String> { - let source=self.current.as_ref().ok_or("Grind has no selected animation")?; - let mut values=[0.;2]; - for i in 0..2 { - let mut tree=source.clone(); - let mut attributes=self.settable.entries().to_vec(); - attributes.push(SettableAttribute {name,value:i as f32,normalized:true,sequence_id:-1}); - tree.set_attributes(&attributes)?; - // During entry the destination tree owns bounds, not the fading-out pose. - let attribute=tree.attributes(15)?.into_iter().find(|a|a.name==name) - .ok_or("Grind animation is missing its twist attribute")?; - values[i]=f32::from_bits(attribute.payload.0[0].ok_or("Grind twist is uninitialized")?); - } - Ok(values) - } pub fn tree_attributes(&self) -> &[AnimationAttribute] { &self.tree_attributes } @@ -180,9 +158,28 @@ impl MotionAnimation { .map(PlaybackTree::length) .ok_or_else(|| "No current animation tree".into()) } + /// MatchAirTime82BBA198..1CC: current tree v20 length, v28 SetTime. + /// Missing current tree skips the seek, but not the caller's parameters. + pub(super) fn synchronize_air_time(&mut self, fraction: f32) { + if let Some(tree) = &mut self.current { + tree.set_time(tree.length() * fraction); + } + } pub fn in_transition(&self) -> bool { self.current.as_ref().is_some_and(has_transition) } + ///JumpInto Update82BACDC0: apply pending parameters, then query the + ///current tree with mask31 and seek to the authored attribute's begin. + ///The caller owns the one-shot instance latch; a missing marker is a miss. + pub(super) fn jump_into(&mut self, name: AttributeName) -> Result<(), String> { + self.apply_parameters()?; + let tree = self.current.as_mut().ok_or("JumpInto requires a current animation tree")?; + let mut attribute = MotionGraphAttribute { name, value: 0.0 }.to_animation(); + if tree.query_attribute(name, 31, &mut attribute)? { + tree.set_time(attribute.begin_time); + } + Ok(()) + } ///825310F0: retire completed transitions before advancing; reset property /// once at the root. Graph evaluation, parametrization and pose evaluation /// remain separate calls so the actor schedule owns their meaningful order. @@ -263,7 +260,7 @@ impl MotionAnimation { } pub fn set_grab_type(&mut self, grab_type: super::motion_stock_gameplay::GrabType) { self.grab_type = Some(grab_type); - //82B971F8 stores ISkaterAnim360; this is not a physics attribute. + self.emit_packet(encode(b"GrabType"), grab_type.as_value()); } pub fn clear_grab_type(&mut self) { self.grab_type = None; @@ -376,14 +373,11 @@ fn prune(tree: PlaybackTree) -> PlaybackTree { PlaybackTree::Transition(transition) } } + PlaybackTree::BlendSpace(mut tree) => {tree.children=tree.children.into_iter().map(prune).collect();PlaybackTree::BlendSpace(tree)} PlaybackTree::PhaseBlend(mut tree) => { tree.children = tree.children.into_iter().map(prune).collect(); PlaybackTree::PhaseBlend(tree) } - PlaybackTree::BlendSpace(mut tree) => { - tree.children = tree.children.into_iter().map(prune).collect(); - PlaybackTree::BlendSpace(tree) - } PlaybackTree::SelectionSpace(mut tree) => { tree.candidates = tree .candidates @@ -515,3 +509,9 @@ impl PlaybackService for MotionAnimation { Ok(true) } } + +#[cfg(test)] +#[path = "tests/animation_jump_into.rs"] +mod jump_into_tests; +#[path = "motion_grind/animation_owner.rs"] +mod grind_owner; diff --git a/crates/skate-game/src/graph_host/motion_animation/stock_clip_query.rs b/crates/skate-game/src/graph_host/motion_animation/stock_clip_query.rs new file mode 100644 index 00000000..635d9ecb --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation/stock_clip_query.rs @@ -0,0 +1,23 @@ +//! Direct named-bank query82D16680, not the currently playing tree/channel. +use super::MotionAnimation; + +impl MotionAnimation { + pub(crate) fn stock_clip_translation_z(&self, database: &str, animation: &str) -> Result { + //82BA7FA4/7FDC: missing bank/clip/attribute leaves the scalar zero. + let Some(source) = self.metadata.source_for(animation) else { return Ok(0.0); }; + let bank = std::path::Path::new(&source.source_bank) + .file_stem().and_then(|name| name.to_str()).unwrap_or(""); + if !bank.eq_ignore_ascii_case(database) { return Ok(0.0); } + let clip = self.metadata.clip(animation)?; + let Some(attribute) = clip.attributes.iter().find(|a| a.name.eq_ignore_ascii_case("AnimTransZ")) + else { return Ok(0.0); }; + //82D16680 requests Attribute::Init82D164F8 at time0, irrespective of + //the event's authored time range. Curve frame is therefore zero. + match attribute.type_id { + 0 | 1 | 3 => attribute.payload_words.first().copied().map(f32::from_bits) + .ok_or_else(|| format!("{database}/{animation}: truncated AnimTransZ")), + 2 => skate_core::animation::playback_clip::sample_curve(&attribute.payload_words, 0.0), + _ => Ok(0.0), //82D1659C: no payload write for other kinds. + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_bump.rs b/crates/skate-game/src/graph_host/motion_bump.rs new file mode 100644 index 00000000..08874204 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_bump.rs @@ -0,0 +1,31 @@ +//! SetBumpCoefficients Begin82BB0660, original Skate 3 TU3. +use skate_data::collections::Collections; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub scale_x_acc: f32, + pub min_bump_mag: f32, + pub max_bump_mag: f32, + pub min_bump_blend_value: f32, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let value = |name| data.float("anim_motion", "bumps", name); + Ok(Self { + scale_x_acc: value("scale_x_acc")?, + min_bump_mag: value("min_bump_mag")?, + max_bump_mag: value("max_bump_mag")?, + min_bump_blend_value: value("min_bump_blend_value")?, + }) + } + + /// Begin82BB0660 uses the retained Xenon arithmetic implementation. + pub fn coefficients(&self, acceleration: [f32; 4], mirrored: bool) -> [f32; 2] { + skate_core::animation::bump::coefficients(acceleration, mirrored, &skate_core::animation::bump::Settings { + scale_x_acc: self.scale_x_acc, + min_bump_mag: self.min_bump_mag, + max_bump_mag: self.max_bump_mag, + min_bump_blend_value: self.min_bump_blend_value, + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_channels.rs b/crates/skate-game/src/graph_host/motion_channels.rs index c2fce7bb..cd15aed3 100644 --- a/crates/skate-game/src/graph_host/motion_channels.rs +++ b/crates/skate-game/src/graph_host/motion_channels.rs @@ -25,9 +25,9 @@ impl MotionChannels { mut prepare: impl FnMut(&mut PlaybackTree) -> Result<(), String>, ) -> Result<(), String> { for channel in &mut self.channels { - if channel.playback.weight > 0.0 { - prepare(&mut channel.tree)?; - } + // Channel::SetAttributes82B96B20 reaches the child even before + // fade-in becomes visible. Prepare its selection subtree likewise. + prepare(&mut channel.tree)?; } Ok(()) } @@ -37,13 +37,6 @@ impl MotionChannels { .any(|c| intent_key(&c.name) == intent_key(name)) } ///82D1D5F8: missing channel returns zero; clamp remaining child time. - ///82D1D4B8: child time clamped to its length. - pub fn time(&self, name: &str) -> f32 { - self.channels - .iter() - .find(|c| intent_key(&c.name) == intent_key(name)) - .map_or(0., |c| c.tree.time().max(0.).min(c.tree.length())) - } pub fn remaining(&self, name: &str) -> f32 { self.channels .iter() @@ -170,9 +163,9 @@ impl MotionChannels { } pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result<(), String> { for c in &mut self.channels { - if c.playback.weight > 0.0 { - c.tree.set_attributes(attributes)?; - } + //82B96B50..64 forwards parameters unconditionally. Weight gates + // pose evaluation (82B965D8), not parameter publication. + c.tree.set_attributes(attributes)?; } Ok(()) } diff --git a/crates/skate-game/src/graph_host/motion_conditions.rs b/crates/skate-game/src/graph_host/motion_conditions.rs index 5b2b180b..59c82e37 100644 --- a/crates/skate-game/src/graph_host/motion_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_conditions.rs @@ -9,23 +9,12 @@ use skate_data::state_graph::attributes::Attributes; #[derive(Clone, Debug, PartialEq)] pub enum MotionCondition { - ///82BA41F0: nonzero PhysOut.Grinds324, independent of grind category. - DroppingIn, - CanBipedLand, - BipedCommitted, - EnoughDistToObstacle { - animation: String, - database: String, - }, - GroundSlope(super::motion_ground_slope::GroundSlopeType), - BipedGroundThin, - DisableDismount, - HoldingSkateboard, - StandingOnMovingObject, - LocoState(super::motion_cadence::LocoState), + Grind(super::motion_grind::conditions::Condition), + HasTweak(NumericCondition), + CurrentGrabType(super::motion_stock_gameplay::GrabType), ManualOutTimerIsActive, - PhysicsWantsManualExit, Gesture(crate::input::gesture_catalog::Group), + DisableDismount(super::motion_dismount::Condition), Landing(super::motion_landing::Condition), Wipeout(super::motion_wipeout::Condition), PushOff(super::motion_push_off::IsPushOffEnabled), @@ -73,38 +62,35 @@ pub enum MotionCondition { }, Gameplay(super::motion_gameplay_conditions::GameplayCondition), Riding(super::motion_riding_conditions::MotionRidingCondition), - /// Native Air condition: PhysOutAnimation collision/trajectory time. + /// TimeToLand82BA7250: valid Air trajectory remaining time. TimeToLand(NumericCondition), - CurrentGrabType(super::motion_stock_gameplay::GrabType), - CrouchedForGrabCycle, - TrucksOrDeckInContact, - HasTweak(NumericCondition), - ///DistToEdge82BA8238: completed OffBoard116, not a fresh nearest-rail query. - DistToEdge(NumericCondition), /// Native off-board trajectory time (PhysOutOffBoard+32). ObTimeToLand(NumericCondition), /// Native off-board trajectory time used by dismount/runout branches. ObTrajTime(NumericCondition), + /// Biped locomotion bucket published by the physical off-board owner. + LocoState(super::motion_offboard_cadence::LocoState), + GroundSlopeType(super::motion_ground_slope::GroundSlopeType), + /// Thin-ground branch of the stock BipedGround state. + IsBipedGroundThin, + IsHoldingSkateboard, + /// Static gameplay has no moving actors yet; the stock condition is a + /// resolved physical flag and must remain false until a moving support is + /// published by the collision owner. + IsStandingOnMovingObject, + /// IsInDebugAnimationsMode is a retail debug gate. TU3 has no gameplay + /// producer for this flag; the shipped runtime never enables debug + /// animation mode, so the condition is a resolved false leaf rather than + /// an unsupported graph node. + DebugAnimationsMode, + /// Stock gameplay predicates whose concrete values are published by the + /// grind/handplant/skitch physical owners. + StockGameplay(super::motion_stock_conditions::Condition), } impl MotionCondition { pub fn parse(a: &Attributes<'_>) -> Result, String> { - if a.text("name") == Some("CanBipedLand") { - return Ok(Some(Self::CanBipedLand)); - } - if a.text("name") == Some("IsBipedCommittedToMotion") { - return Ok(Some(Self::BipedCommitted)); - } - if a.text("name") == Some("EnoughDistToObstacle") { - return Ok(Some(Self::EnoughDistToObstacle { - animation: a - .text("anim") - .ok_or("EnoughDistToObstacle requires anim")? - .into(), - database: a - .text("db") - .ok_or("EnoughDistToObstacle requires db")? - .into(), - })); + if let Some(condition) = super::motion_grind::conditions::Condition::parse(a)? { + return Ok(Some(Self::Grind(condition))); } if let Some(condition) = super::motion_landing::Condition::parse(a) { return Ok(Some(Self::Landing(condition))); @@ -115,17 +101,21 @@ impl MotionCondition { if let Some(condition) = super::motion_push_off::IsPushOffEnabled::parse(a) { return Ok(Some(Self::PushOff(condition))); } + if let Some(condition) = super::motion_dismount::Condition::parse(a) { + return Ok(Some(Self::DisableDismount(condition))); + } let numeric = || super::condition_nodes::numeric(a); - Ok(Some(match a.text("name").unwrap_or("") { - "GroundSlopeType" => { - Self::GroundSlope(super::motion_ground_slope::GroundSlopeType::parse(a)?) - } - "IsStandingOnMovingObject" => Self::StandingOnMovingObject, - "DisableDismount" => Self::DisableDismount, - "IsHoldingSkateboard" => Self::HoldingSkateboard, //82BA5E10 - "IsBipedGroundThin" => Self::BipedGroundThin, //82BA8110: OffBoard+330 - "LocoState" => Self::LocoState(super::motion_cadence::LocoState::parse(a)?), - "PhysicsWantsManualExit" => Self::PhysicsWantsManualExit, + let name = a + .text("name") + .unwrap_or("") + .trim_matches(|c: char| c.is_whitespace() || c == '\0'); + Ok(Some(match name { + "HasTweak" => Self::HasTweak(numeric()), + "CurrentGrabType" => Self::CurrentGrabType( + super::motion_stock_gameplay::GrabType::parse( + a.text("grab").ok_or("CurrentGrabType requires grab")?, + )?, + ), "ManualOutTimerIsActive" => Self::ManualOutTimerIsActive, "HasGestureIntent" => Self::Gesture(crate::input::gesture_catalog::Group::parse( a.text("group").unwrap_or(""), @@ -187,85 +177,46 @@ impl MotionCondition { name if super::motion_riding_conditions::MotionRidingCondition::recognizes(name) => { Self::Riding(super::motion_riding_conditions::MotionRidingCondition::parse(a)?) } - "DistToEdge" => Self::DistToEdge(numeric()), - "IsDroppingIn" => Self::DroppingIn, - "TrucksOrDeckInContact" => Self::TrucksOrDeckInContact, - "HasTweak" => Self::HasTweak(numeric()), - "CurrentGrabType" => Self::CurrentGrabType(super::motion_stock_gameplay::GrabType::parse(a.text("grab").unwrap_or(""))?), - "IsCrouchedEnoughForBlendToGrabCycle" => Self::CrouchedForGrabCycle, "TimeToLand" => Self::TimeToLand(numeric()), "OBTimeToLand" => Self::ObTimeToLand(numeric()), "OBTrajTime" => Self::ObTrajTime(numeric()), + "LocoState" => Self::LocoState(super::motion_offboard_cadence::LocoState::parse(a)?), + "GroundSlopeType" => Self::GroundSlopeType(super::motion_ground_slope::GroundSlopeType::parse(a)?), + "IsBipedGroundThin" => Self::IsBipedGroundThin, + "IsHoldingSkateboard" => Self::IsHoldingSkateboard, + "IsStandingOnMovingObject" => Self::IsStandingOnMovingObject, + "IsInDebugAnimationsMode" => Self::DebugAnimationsMode, + name if super::motion_stock_conditions::Condition::recognizes(name) => { + Self::StockGameplay(super::motion_stock_conditions::Condition::parse(a)) + } _ => return Ok(super::condition_nodes::parse(a)?.map(Self::Shared)), })) } pub fn evaluate(&self, host: &MotionHost, frame: &Frame) -> Result { use skate_core::animation::playback_parameters::ParameterInputs; Ok(match self { - //82BA42E0 reads Collision3472 (trucks) OR3475 (deck), not wheels. - Self::TrucksOrDeckInContact => host.gameplay_conditions - .ok_or("Contact condition requires physical publication")?.trucks_or_deck_contact, - //82BA6B90 shares the filtered-map accessor with82BA6AC0. At least - //one component must exist even when no numeric comparison is authored. - Self::HasTweak(n) => { + Self::Grind(condition) => condition.evaluate( + host.grind_conditions.as_ref() + .ok_or("Grind condition requires completed physical output")? + ), + // 82BA7760/82BA7848 compares the authored type through + // ISkaterAnim; SetGrabType Begin/End writes that same owner. + Self::CurrentGrabType(grab) => host.animation.grab_type == Some(*grab), + // TU3 82BA6B90 uses the filtered MG map (virtual +12), + // requires at least one axis, then compares vector magnitude. + Self::HasTweak(numeric) => { let x = host.animation.filtered_intent("TweakX"); let y = host.animation.filtered_intent("TweakY"); - (x.is_some() || y.is_some()) && { - let (x, y) = (x.unwrap_or(0.0), y.unwrap_or(0.0)); - n.matches(skate_core::input::controller::magnitude(y.mul_add(y, x * x))) - } - }, - //82BA7848 -> ISkaterAnim+100 ->82B97200 compares the enum at360. - Self::CurrentGrabType(grab) => host.animation.grab_type == Some(*grab), - //82BBDE40: PhysOutSkeleton72 < literal821EE79C (strictly 0.6). - Self::CrouchedForGrabCycle => host.crouching_physical - .ok_or("Grind grab requires the published skeleton height")? - .animation_height_72 < f32::from_bits(0x3f19_999a), - Self::CanBipedLand => host.offboard_output.landing_normal_192[1] > 0.85, - Self::BipedCommitted => host.offboard_output.flag_329 != 0, - Self::EnoughDistToObstacle { - animation, - database: _, - } => { - //82D16680 looks up the literal clip and initializes AnimTransZ at - //time zero; a missing bank/clip/attribute leaves the caller's zero. - let mut distance = 0.; - if let Ok(clip) = host.animation.metadata().clip(animation) { - if let Some(attribute) = clip - .attributes - .iter() - .find(|a| encode(a.name.as_bytes()) == encode(b"AnimTransZ")) - { - distance = match attribute.type_id { - 0 => f32::from_bits( - *attribute - .payload_words - .first() - .ok_or("Truncated AnimTransZ")?, - ), - 2 => skate_core::animation::playback_clip::sample_curve( - &attribute.payload_words, - 0., - )?, - _ => 0., - }; - } - } - host.offboard_output.scalar_112 >= distance + 0.3 + (x.is_some() || y.is_some()) + && (numeric.comparison == Comparison::None + || numeric.matches((x.unwrap_or(0.0).powi(2) + + y.unwrap_or(0.0).powi(2)).sqrt())) } - Self::GroundSlope(condition) => condition.matches(host.offboard_slope), - //82BA5B80: abs(z)+abs(x)+speed, in source addition order. - Self::StandingOnMovingObject => { - (host.offboard_support[2].abs() + host.offboard_support[1].abs()) - + host.offboard_support[0] - > f32::from_bits(0x3c23d70a) - } - Self::DisableDismount => host.disable_dismount, - Self::HoldingSkateboard => host.toggle_board_physical.held, - Self::BipedGroundThin => host.offboard_ground_thin, - Self::LocoState(condition) => condition.evaluate(host.offboard_locomotion), + // Native 82BA78B0: strictly positive retained manual-out timer. + Self::ManualOutTimerIsActive => host.riding.manual_out_timer > 0.0, Self::ShouldLeaveSlide { right } => host.slide_latch.should_leave(*right), - Self::Gesture(group) => group.has_intent(|name| host.action_intents.contains_key(name)), + Self::Gesture(group) => group.has_intent(|name| host.action_controls.has(name)), + Self::DisableDismount(condition) => condition.evaluate(host.condition_inputs.push_brake.as_ref())?, Self::Shared(condition) => condition .evaluate( &host.condition_inputs, @@ -274,24 +225,42 @@ impl MotionCondition { &host.state_parents, ) .map_err(str::to_owned)?, - //82BA78B0 -> specific getter: strictly positive retained timer. - Self::PhysicsWantsManualExit => host.manual_exit.ok_or("PhysicsWantsManualExit requires completed Animation168")?, - Self::ManualOutTimerIsActive => host.riding.manual_out_timer > 0.0, Self::Gameplay(condition) => condition.evaluate(host)?, Self::Riding(condition) => condition.evaluate(host)?, - Self::DistToEdge(n) => n.matches(host.offboard_output.distance_116), - Self::DroppingIn => host.grind_physical.dropping_in, Self::TimeToLand(n) => { - let p = host.gameplay_conditions + let p = host.gameplay_conditions.as_ref() .ok_or("TimeToLand requires physical condition publication")?; p.time_to_land_valid && n.matches(p.time_to_land) - }, - Self::ObTimeToLand(n) | Self::ObTrajTime(n) => n.matches( - host.gameplay_conditions - .as_ref() - .ok_or("OB trajectory condition requires physical condition publication")? + } + Self::ObTimeToLand(n) => n.matches( + host.gameplay_conditions.as_ref() + .ok_or("OBTimeToLand requires physical condition publication")? .offboard_time_to_land, ), + Self::ObTrajTime(n) => { + let p = host.gameplay_conditions.as_ref() + .ok_or("OBTrajTime requires physical condition publication")?; + p.offboard_trajectory_valid && n.matches(p.offboard_trajectory_time) + } + Self::DebugAnimationsMode => false, + Self::LocoState(condition) => condition.evaluate( + host.offboard_locomotion_state + .ok_or("LocoState requires retained Biped locomotion through OffBoard84")?, + ), + Self::GroundSlopeType(condition) => condition.matches( + host.ground_slope_type + .ok_or("GroundSlopeType requires the completed ground slope publication")?, + ), + Self::IsBipedGroundThin => host + .biped_ground_thin + .ok_or("IsBipedGroundThin requires the native ground geometry publication")?, + Self::IsHoldingSkateboard => host.toggle_board_physical + .is_some_and(|p| p.holding_board), + Self::IsStandingOnMovingObject => host + .gameplay_conditions + .ok_or("IsStandingOnMovingObject requires the physical state publication")? + .moving_object, + Self::StockGameplay(condition) => condition.evaluate(host)?, Self::PushOff(condition) => condition.evaluate(), Self::Wipeout(condition) => condition.evaluate(host.wipeout_physical)?, Self::Landing(condition) => condition.evaluate( diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index 4b49d6d8..3e5fda2a 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -1,90 +1,61 @@ -//! Original behavior lifecycle dispatch for the persistent MotionHost. use super::*; -use crate::graph_host::motion_kickturn::{self, Operation as KickTurnOperation}; - impl MotionHost { - pub(super) fn execute( - &mut self, - behavior: BehaviorId, - frame: &Frame, - phase: u8, - ) -> Result<(), String> { - let id = *self - .remap - .behaviors - .get(behavior) - .ok_or("Unbound MotionGraph behavior")?; + pub(super) fn execute(&mut self, behavior: BehaviorId, frame: &Frame, phase: u8) -> Result<(), String> { + let id = *self.remap.behaviors.get(behavior).ok_or("Unbound MotionGraph behavior")?; let operation = self.operations[id].clone(); - if let MotionOperation::StockGameplay(operation) = operation { - return super::stock_execution::execute(self, operation, phase); - } - if let MotionOperation::EndGesture = operation { - if phase == 0 { self.end_gesture_channels(); } - return Ok(()); - } if let MotionOperation::Grind(operation) = operation { - let Instance::Grind(state)=&mut self.instances[behavior] else {return Err("Grind instance mismatch".into())}; - let height=self.crouching_physical.map(|p|p.animation_height_72).ok_or("Grind requires physical height")?; - return super::super::motion_grind::execute(state,&operation,phase,frame.dt, - &self.grind_settings,&self.grind_physical,height,&mut self.animation); - } - if let MotionOperation::OffboardAir(operation) = operation { - let Instance::OffboardAir(state) = &mut self.instances[behavior] else { - return Err("OffboardAir instance mismatch".into()); - }; - state.execute( - &operation, - phase, - self.offboard_output, - &self.action_intents, - &mut self.animation, - )?; - if let Some(value) = state.seed_write { - self.wipeout_controls.seed_from_air_tweak = value; + if phase == 2 || (phase == 0 && matches!(operation, + crate::graph_host::motion_grind::Operation::Attributes)) { + return Ok(()); } - return Ok(()); - } - if let MotionOperation::ToggleBoard = operation { - let Instance::ToggleBoard(state) = &mut self.instances[behavior] else { - return Err("ToggleBoard instance mismatch".into()); + let mut physical = self.grind_physical + .ok_or("Grind graph requires completed physical grind observations")?; + if (phase == 0 && matches!(operation, + crate::graph_host::motion_grind::Operation::Fade { .. })) + || (phase == 1 && physical.grinding && matches!(operation, + crate::graph_host::motion_grind::Operation::Attributes)) { + physical.animation_mirrored = self.animation.skater_animation_flags + .ok_or("Grind graph requires live animation stance")? + & 0x4000_0000 != 0; + } + let Instance::Grind(state) = self.instances.get_mut(behavior) + .ok_or("Unallocated grind behavior")? else { + return Err("Grind operation/instance mismatch".into()); }; - return state.execute( - phase, - self.toggle_board_physical, - &self.action_intents, - &mut self.animation, + return crate::graph_host::motion_grind::execute( + state, &operation, phase, frame.dt, &self.grind_settings, + &physical, &mut self.animation, ); } - if let MotionOperation::Cadence(operation) = operation { - use crate::graph_host::motion_cadence::{Operation, biped_cadence}; - match operation { - Operation::BipedCadence if phase == 1 => { - let mut value = self.offboard_phase; - biped_cadence(Some(self.offboard_phase), Some(&mut value), phase); - self.phase_write = Some(value); - } - Operation::MatchCadence => { - let Instance::Cadence(state) = &mut self.instances[behavior] else { - return Err("Cadence operation/instance mismatch".into()); - }; - match phase { - 0 => state.begin(Some(self.offboard_phase), true), - 1 => state.update(Some(&mut self.animation)), - _ => state.end(), - } - } - _ => {} - } - return Ok(()); - } if let MotionOperation::Trick(operation) = operation { - let Some(&Instance::Trick(mut updates)) = self.instances.get(behavior) else { + let instance = std::mem::replace(&mut self.instances[behavior], Instance::Stateless); + let Instance::Trick(mut updates) = instance else { return Err("Trick operation/instance mismatch".into()); }; - let result = super::super::motion_tricks::execute(self, operation, &mut updates, phase); + let result = crate::graph_host::motion_tricks::execute(self, operation, &mut updates, phase); self.instances[behavior] = Instance::Trick(updates); return result; } + if matches!(operation, MotionOperation::ToggleBoard) { + return self.execute_toggle_board(behavior, phase); + } + if let MotionOperation::Runout(_) = operation { + // The native producer samples a physical runout bundle at Begin. + // This host does not yet publish that bundle, so retain the + // source-backed state without inventing angle/speed values. + let Instance::Runout(state) = self.instances.get_mut(behavior) + .ok_or("Unallocated runout behavior")? else { + return Err("Runout operation/instance mismatch".into()); + }; + if phase == 0 { + state.begin(self.runout_physical); + return Ok(()); + } + return state.update(Some(&mut self.animation)).map_err(str::to_owned); + } + if self.execute_score_or_bump(&operation, phase)? { + return Ok(()); + } if let MotionOperation::Slide(operation) = operation { return self.execute_slide(behavior, operation, frame, phase); } @@ -162,502 +133,6 @@ impl MotionHost { phase, ); } - let instance = self - .instances - .get_mut(behavior) - .ok_or("Unallocated MotionGraph behavior")?; - match (operation, instance) { - (MotionOperation::SetManualAngle, Instance::Manual(state)) => { - match phase { - 0 => state.begin(), - 1 => { - let value = state.update(self.animation.motion_intent("Manual"), &self.manual); - self.animation.set_attribute(SettableAttribute { - // Factory82BC9678 binds the stock name without an authored override. - name: encode(b"manual_angle"), value, normalized: false, sequence_id: -1, - }); - } - _ => {} // Native End is the empty leaf. - } - } - - (MotionOperation::AddRunoutAttribs, Instance::Runout(state)) => { - if phase == 0 { - *state = Some(super::super::motion_runout::capture( - self.runout_physical - .ok_or("AddRunoutAttribs requires completed physical output")?, - self.animation - .skater_animation_flags - .ok_or("AddRunoutAttribs requires animation stance")? - & 0x4000_0000 - != 0, - )); - } else if phase == 1 { - let values = state.ok_or("AddRunoutAttribs updated before Begin")?; - for (name, value) in [ - ("BipedStartAngle", values.angle_degrees), - ("BipedSpeed", values.speed), - ] { - self.animation.set_attribute(SettableAttribute { - name: encode(name.as_bytes()), - value, - normalized: false, - sequence_id: -1, - }); - } - } - } - //Retail ctor82BA58E8 retains only diagnostic text/layout. All three - //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. - (MotionOperation::PrintText2D, _) => {} - (MotionOperation::SetBumpCoefficients(names), _) => { - // Begin only: vtable823200C8+48; Update/End are82B61BB8. - if phase == 0 { - use skate_core::animation::playback_parameters::{ - AttributeSink, SettableAttribute, - }; - let acceleration = self - .bump_acceleration - .ok_or("SetBumpCoefficients requires PhysOutAnimation112")?; - let flags = self - .animation - .skater_animation_flags - .ok_or("SetBumpCoefficients requires live ISkaterAnim stance")?; - let values = skate_core::animation::bump::coefficients( - acceleration, - flags & 0x4000_0000 != 0, - &self.bump_settings, - ); - for (name, value) in names.into_iter().zip(values) { - AttributeSink::set_attribute( - &mut self.animation, - SettableAttribute { - name, - value, - normalized: false, - sequence_id: -1, - }, - ); - } - } - } - (MotionOperation::Shove(operation), Instance::Shove(state)) => match phase { - 0 => state.begin(), - 1 => state.update( - &operation, - &mut self.animation, - self.shove_physical - .ok_or("Shove requires actual interaction output")?, - self.hand_services.busy_hands, - self.playback_context - .is_mirrored - .ok_or("Shove requires animation stance")?, - )?, - _ => state.end(&mut self.animation, self.hand_services.keep_shove_channels), - }, - (MotionOperation::CharacterGesture, Instance::CharacterGesture(state)) => { - if phase == 1 { - let physical = self.gesture_physical.ok_or( - "CharacterGesture requires original physical and hostgesture publication", - )?; - let gesture_intents = self.animation.motion_intents.clone(); - let inputs = - crate::graph_host::motion_character_gesture::CharacterGestureInputs { - motion_intents: &gesture_intents, - busy_hands: self.hand_services.busy_hands, - filtered_state: self - .condition_inputs - .physical_state - .as_ref() - .ok_or("CharacterGesture requires filteredcategory")? - .category, - ground321: physical.ground321, - board_held311: self - .playback_context - .board_available - .ok_or("CharacterGesture requires boardheldbyte311")?, - state_offboard75: physical.state_offboard75, - distance_to_cog: self - .crouching_physical - .ok_or("CharacterGesture requires actualheight")? - .animation_height_72, - selections: physical.selections, - suppress_up: physical.suppress_up, - force_brake_bypass: physical.force_brake_bypass, - }; - if let Some(output) = state.update(&mut self.animation, inputs)? { - self.gesture_publication = Some(output); - } - } - } - ( - MotionOperation::Native( - crate::graph_host::motion_native::Operation::StoreLandingData, - ), - Instance::LandingData(state), - ) => { - if phase == 1 { - state.update( - self.condition_inputs - .physical_state - .as_ref() - .ok_or("StoreLandingData requires physical category")? - .category, - self.native_physical - .ok_or("StoreLandingData requires raw physical COM velocity/up")?, - &mut self.riding, - ); - } - } - ( - MotionOperation::Native(crate::graph_host::motion_native::Operation::EndShimmy( - time, - )), - _, - ) => { - if phase == 0 { - crate::graph_host::motion_native::end_shimmy(&mut self.animation, time); - } - } - ( - MotionOperation::Native(crate::graph_host::motion_native::Operation::Score { - regular, - mirrored, - }), - _, - ) => { - if phase == 1 { - self.score_packet.set( - if self - .playback_context - .is_mirrored - .ok_or("Score augmentation requires animation stance")? - { - mirrored - } else { - regular - }, - ); - } - } - (MotionOperation::KickTurn(KickTurnOperation::ResetTimer), _) => { - //82BAE8D0 is both Begin and End; v136=8258F930 clears3224. - if phase == 0 || phase == 2 { - self.riding.time_since_kickturn = 0.0; - } - } - ( - MotionOperation::KickTurn(KickTurnOperation::Steering(parameters)), - Instance::KickTurn(state), - ) => { - motion_kickturn::execute( - state, - parameters, - &self.kickturn, - &mut self.animation, - frame.dt, - phase, - )?; - } - (MotionOperation::FakieHeadChannel, Instance::FakieHead(state)) => { - if phase == 1 { - let fakie = self - .animation - .skater_animation_flags - .ok_or("FakieHeadChannel requires actual animation flags")? - & 0x20000000 - != 0; - state.update( - &mut self.animation, - self.flags.manualing, - self.is_power_sliding, - fakie, - )?; - } - } - (MotionOperation::Pumping, Instance::Pumping(state)) => { - const NAMES: [&str; 5] = ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"]; //82F883C8 - if phase == 2 { - for name in NAMES { - self.animation.channels.end(name); - } - } else if phase == 1 && self.allow_pumping { - let occupied = std::array::from_fn(|i| self.animation.channels.has(NAMES[i])); - let update = state.update( - self.pumping_acceleration - .ok_or("Pumping requires actual ground pumping acceleration")?, - occupied, - &self.pumping, - ); - if let Some(index) = update.start { - let settings = skate_core::animation::channel_playback::ChannelSettings { - priority: 0, - keep_alive: false, - mirrored: false, - speed: 1.0, - blend_in: self.pumping.blend_in, - hold_during_blend_in: false, - blend_out: self.pumping.blend_out, - hold_during_blend_out: true, - use_attributes: false, - }; - self.animation - .new_channel(NAMES[index], "B_PUMP", settings)?; - } - if let Some((index, value)) = update.influence { - self.animation.channels.influence(NAMES[index], value); - } - } - } - (MotionOperation::DisallowPumping, _) => { - if phase == 0 { - for name in ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"] { - self.animation - .channels - .end_with(name, f32::from_bits(0x3dcccccd), false); - } - self.allow_pumping = false; - } else if phase == 2 { - self.allow_pumping = true; - } - } - (MotionOperation::SetSpeed { name, value }, _) => { - if phase == 1 { - //82BB0948 sets a tree parameter; it does not call SetSpeed - //on the animation clock. Source uses abs(PhysOutMotion164). - let value = match value { - Some(value) => value, - None => { - self.crouching_physical - .ok_or("SetSpeed requires actual ground-projected speed")? - .body_164 - } - }; - self.animation.set_attribute(SettableAttribute { - name, - value: value.abs(), - normalized: false, - sequence_id: -1, - }); - } - } - (MotionOperation::UpdateRidingFakie(settings), Instance::RidingFakie(state)) => { - if phase == 1 { - let mut physical = self - .fakie_physical - .ok_or("UpdateRidingFakie requires actual physical outputs")?; - physical.doing_trick = self.flags.doing_trick; - if let Some(fakie) = state.update(physical, frame.dt, settings) { - let flags = self - .animation - .skater_animation_flags - .as_mut() - .ok_or("UpdateRidingFakie requires actual animation flags")?; - *flags = (*flags & !0x20000000) | if fakie { 0x20000000 } else { 0 }; - } - } - } - (MotionOperation::SettingBodyTilt(name), Instance::BodyTilt(instance)) => { - if phase == 1 && self.applying_body_tilt { - if let Some(value) = instance.update( - true, - self.playback_context - .is_mirrored - .ok_or("SettingBodyTilt needs actual mirrored stance")?, - self.body_tilt_physical - .ok_or("SettingBodyTilt needs actual PhysOut tilt and spin")?, - &self.body_tilt, - ) { - self.animation.set_attribute(SettableAttribute { - name, - value, - normalized: false, - sequence_id: -1, - }); - } - } else if phase == 1 { - // Disabled native branch updates the enable latch without - // reading physics or emitting a parameter. - instance.disable(); - } - } - (MotionOperation::Play(operation), Instance::Play(instance)) => match phase { - 0 => instance.begin(&operation, &mut self.playback_context, &mut self.animation)?, - 1 => instance.update(&operation, &mut self.animation)?, - _ => instance.end(), - }, - ( - MotionOperation::AttachIntent { - intent, - attribute, - set, - }, - _, - ) => { - if phase == 1 { - self.animation.attach(&intent, attribute, set); - } - } - (MotionOperation::Riding(operation), _) => self.riding.execute( - operation, - phase, - frame, - &mut self.animation, - self.condition_inputs - .physical_state - .as_ref() - .map(|p| p.category), - self.crouching_physical.map(|p| p.animation_height_72), - self.flags.manualing, - )?, - (MotionOperation::ApplyingBodyTilt, _) => { - if phase == 0 { - self.applying_body_tilt = true; - } else if phase == 2 { - self.applying_body_tilt = false; - } - } - (MotionOperation::Crouching(name), Instance::Crouching(state)) => { - use skate_core::animation::crouching; - if phase == 0 { - *state = Some(crouching::State::begin( - self.crouching_physical - .ok_or("Crouching Begin requires real physical height and speed")?, - &self.crouching, - )); - } else if phase == 1 { - let intents = crouching::Intents { - auto_pump_angle: self.animation.motion_intent("AutoPumpAngle"), - auto_pump_magnitude: self.animation.motion_intent("AutoPumpMag"), - crouch: self.animation.motion_intent("Crouch"), - hard_turn_crouch: self.animation.motion_intent("HardTurnCrouch"), - manual: self.animation.motion_intent("Manual"), - motion_flag_108: self.is_power_sliding, - }; - let result = state - .as_mut() - .ok_or("Crouching updated before Begin")? - .update( - self.crouching_physical - .ok_or("Crouching requires actual physical outputs")?, - intents, - frame.dt, - &self.crouching, - ); - if result.new_auto_pump { - self.animation.emit_packet(encode(b"NewAutoPump"), 1.0); - } - if result.player_controlled_pump { - self.animation - .emit_packet(encode(b"PlayerControlledPump"), 1.0); - } - self.animation.set_attribute(SettableAttribute { - name, - value: result.height, - normalized: false, - sequence_id: -1, - }); - } - } - (MotionOperation::SetTurning(names), Instance::Turning(instance)) => { - if phase == 0 { - instance.enter(); - } else if phase == 1 { - let physical = self - .physical - .ok_or("SetTurning requires actual PhysOutAnimation and stance outputs")?; - let intents = set_turning::Intents { - fakie_turn: self.animation.motion_intent("FakieTurn"), - mode_0_slide: self.animation.motion_intent("LeftSlide"), - mode_1_slide: self.animation.motion_intent("RightSlide"), - }; - //ISkaterAnim v12=82B970D8/v28=82B97140 read live bits29/30. - //UpdateRidingFakie may have changed them earlier in this graph tick. - let flags = self - .animation - .skater_animation_flags - .ok_or("SetTurning requires actual animation stance flags")?; - set_turning::update( - instance, - &mut self.slide_latch, - physical.turning, - (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), - frame.dt, - &self.turning, - intents, - |attribute, value| { - let index = match attribute { - set_turning::Attribute::Angle => Some(0), - set_turning::Attribute::Direction => Some(1), - set_turning::Attribute::Quickness => Some(2), - set_turning::Attribute::Speed => Some(3), - set_turning::Attribute::Holding => Some(4), - _ => None, - }; - if let Some(index) = index { - self.animation.set_attribute(SettableAttribute { - name: names[index], - value, - normalized: false, - sequence_id: -1, - }); - } else { - self.animation.emit_packet( - encode(if attribute == set_turning::Attribute::Turn { - b"Turn" - } else { - b"Slide" - }), - value, - ); - } - }, - ); - } - } - (MotionOperation::Push(_), Instance::Push(instance)) => { - let physical = self - .physical - .ok_or("Push behavior requires actual skater physical outputs")?; - // The attribute sink borrows animation mutably during this - // callback; retain a snapshot of the same canonical intent map. - let intents = self.animation.motion_intents.clone(); - let mut context = PushContext { - settings: &self.pushing, - shared: self - .push_state - .as_mut() - .ok_or("Native push state initialization has not been published")?, - motion_intents: &intents, - forward_speed: physical.forward_speed, - delta_seconds: frame.dt, - time_since_teleport: self.riding.time_since_teleport, - is_switch: physical.is_switch, - foot_frame: physical.foot_frame, - }; - match phase { - 0 => instance.begin(&mut context, &mut self.animation), - 1 => instance.update(&mut context, &mut self.animation)?, - _ => instance.end(&mut context), - } - } - (MotionOperation::JumpInto(name), Instance::JumpInto(pending)) => { - // TU3 82BACDC0: Update consumes the allocation-time latch even - // if the authored marker is absent. Begin/End are empty. - if phase == 1 && *pending { - self.animation.jump_into(name)?; - *pending = false; - } - } - (MotionOperation::Unsupported { kind, name }, _) => { - return Err(format!("Unsupported MotionGraph {kind:?} {name}")); - } - (operation, _) => { - return Err(format!( - "Invalid MotionGraph behavior instance {operation:?}" - )); - } - } - Ok(()) + return leaf_execution::execute(self, behavior, operation, frame, phase); } } diff --git a/crates/skate-game/src/graph_host/motion_execute_leaf.rs b/crates/skate-game/src/graph_host/motion_execute_leaf.rs new file mode 100644 index 00000000..0b68aa9e --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_execute_leaf.rs @@ -0,0 +1,647 @@ +use super::*; +use crate::graph_host::motion_kickturn::{self, Operation as KickTurnOperation}; +pub(super) fn execute( + host: &mut MotionHost, + behavior: BehaviorId, + operation: MotionOperation, + frame: &Frame, + phase: u8, +) -> Result<(), String> { + let instance = host.instances.get_mut(behavior) + .ok_or("Unallocated MotionGraph behavior")?; + match (operation, instance) { + (MotionOperation::BipedCadence, _) => { + if phase == 1 { + crate::graph_host::motion_offboard_cadence::biped_cadence( + host.offboard_cadence_phase, + Some(&mut host.animation_phase), + phase, + ); + } + } + (MotionOperation::MatchCadence, Instance::MatchCadence(state)) => { + match phase { + 0 => state.begin(host.offboard_cadence_phase, true), + 1 => state.update(Some(&mut host.animation)), + _ => state.end(), + } + } + (MotionOperation::OffboardBodyTweakBlend(settings), Instance::OffboardBodyTweak(state)) => { + match phase { + 0 => state.begin(), + 1 => { + let physical = host.gameplay_conditions.as_ref() + .ok_or("OffboardBodyTweakBlend requires completed OffBoard output")?; + state.update( + &settings, &mut host.animation, &mut host.wipeout_controls, + body_tweak::Physical { + time_to_land: physical.offboard_time_to_land, + scalar_92: physical.offboard_air_scalar_92, + board_held: host.playback_context.board_available + .ok_or("OffboardBodyTweakBlend requires OffBoard311")?, + }, + )?; + } + _ => state.end(&mut host.animation), + } + } + (MotionOperation::SetDeckPitchAndYaw, _) => { + return Err("SetDeckPitchAndYaw has no recovered native implementation".into()); + } + //Retail ctor82BA58E8 retains only diagnostic text/layout. All three + //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. + (MotionOperation::PrintText2D, _) => {} + (MotionOperation::Shove(operation), Instance::Shove(state)) => match phase { + 0 => state.begin(), + 1 => state.update( + &operation, + &mut host.animation, + host.shove_physical + .ok_or("Shove requires actual interaction output")?, + host.hand_services.busy_hands, + host.playback_context + .is_mirrored + .ok_or("Shove requires animation stance")?, + )?, + _ => state.end(&mut host.animation, host.hand_services.keep_shove_channels), + }, + (MotionOperation::CharacterGesture, Instance::CharacterGesture(state)) => { + if phase == 1 { + let physical = host.gesture_physical.ok_or( + "CharacterGesture requires original physical and hostgesture publication", + )?; + let gesture_intents = host.animation.motion_intents.clone(); + let inputs = + crate::graph_host::motion_character_gesture::CharacterGestureInputs { + motion_intents: &gesture_intents, + busy_hands: host.hand_services.busy_hands, + filtered_state: host + .condition_inputs + .physical_state + .as_ref() + .ok_or("CharacterGesture requires filteredcategory")? + .category, + ground321: physical.ground321, + board_held311: host + .playback_context + .board_available + .ok_or("CharacterGesture requires boardheldbyte311")?, + state_offboard75: physical.state_offboard75, + distance_to_cog: host + .crouching_physical + .ok_or("CharacterGesture requires actualheight")? + .animation_height_72, + selections: physical.selections, + suppress_up: physical.suppress_up, + force_brake_bypass: physical.force_brake_bypass, + }; + if let Some(output) = state.update(&mut host.animation, inputs)? { + host.gesture_publication = Some(output); + } + } + } + (MotionOperation::EndGesture, _) => { + if phase == 0 { + // EndGesture has no independent instance; it tears down + // the persistent CharacterGesture owner. + host.end_gesture_channels(); + } + } + ( + MotionOperation::Native( + crate::graph_host::motion_native::Operation::StoreLandingData, + ), + Instance::LandingData(state), + ) => { + if phase == 1 { + state.update( + host.condition_inputs + .physical_state + .as_ref() + .ok_or("StoreLandingData requires physical category")? + .category, + host.native_physical + .ok_or("StoreLandingData requires raw physical COM velocity/up")?, + &mut host.riding, + ); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::EndShimmy( + time, + )), + _, + ) => { + if phase == 0 { + crate::graph_host::motion_native::end_shimmy(&mut host.animation, time); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::Score { + regular, + mirrored, + }), + _, + ) => { + if phase == 1 { + host.score_packet.set( + if host + .playback_context + .is_mirrored + .ok_or("Score augmentation requires animation stance")? + { + mirrored + } else { + regular + }, + ); + } + } + (MotionOperation::KickTurn(KickTurnOperation::ResetTimer), _) => { + //82BAE8D0 is both Begin and End; v136=8258F930 clears3224. + if phase == 0 || phase == 2 { + host.riding.time_since_kickturn = 0.0; + } + } + ( + MotionOperation::KickTurn(KickTurnOperation::Steering(parameters)), + Instance::KickTurn(state), + ) => { + motion_kickturn::execute( + state, + parameters, + &host.kickturn, + &mut host.animation, + frame.dt, + phase, + )?; + } + (MotionOperation::FakieHeadChannel, Instance::FakieHead(state)) => { + if phase == 1 { + let fakie = host + .animation + .skater_animation_flags + .ok_or("FakieHeadChannel requires actual animation flags")? + & 0x20000000 + != 0; + state.update( + &mut host.animation, + host.flags.manualing, + host.is_power_sliding, + fakie, + )?; + } + } + (MotionOperation::Pumping, Instance::Pumping(state)) => { + const NAMES: [&str; 5] = ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"]; //82F883C8 + if phase == 2 { + for name in NAMES { + host.animation.channels.end(name); + } + } else if phase == 1 && host.allow_pumping { + let occupied = std::array::from_fn(|i| host.animation.channels.has(NAMES[i])); + let update = state.update( + host.pumping_acceleration + .ok_or("Pumping requires actual ground pumping acceleration")?, + occupied, + &host.pumping, + ); + if let Some(index) = update.start { + let settings = skate_core::animation::channel_playback::ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: host.pumping.blend_in, + hold_during_blend_in: false, + blend_out: host.pumping.blend_out, + hold_during_blend_out: true, + use_attributes: false, + }; + host.animation + .new_channel(NAMES[index], "B_PUMP", settings)?; + } + if let Some((index, value)) = update.influence { + host.animation.channels.influence(NAMES[index], value); + } + } + } + (MotionOperation::DisallowPumping, _) => { + if phase == 0 { + for name in ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"] { + host.animation + .channels + .end_with(name, f32::from_bits(0x3dcccccd), false); + } + host.allow_pumping = false; + } else if phase == 2 { + host.allow_pumping = true; + } + } + (MotionOperation::SetSpeed { name, value }, _) => { + if phase == 1 { + //82BB0948 sets a tree parameter; it does not call SetSpeed + //on the animation clock. Source uses abs(PhysOutMotion164). + let value = match value { + Some(value) => value, + None => { + host.crouching_physical + .ok_or("SetSpeed requires actual ground-projected speed")? + .body_164 + } + }; + host.animation.set_attribute(SettableAttribute { + name, + value: value.abs(), + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::UpdateRidingFakie(settings), Instance::RidingFakie(state)) => { + if phase == 1 { + let mut physical = host + .fakie_physical + .ok_or("UpdateRidingFakie requires actual physical outputs")?; + physical.doing_trick = host.flags.doing_trick; + if let Some(fakie) = state.update(physical, frame.dt, settings) { + let flags = host + .animation + .skater_animation_flags + .as_mut() + .ok_or("UpdateRidingFakie requires actual animation flags")?; + *flags = (*flags & !0x20000000) | if fakie { 0x20000000 } else { 0 }; + } + } + } + (MotionOperation::StockGameplay(operation), instance) => { + // These are graph-owned lifecycle records. Their live values + // are published through the same host state consumed by the + // matching conditions; the authored node itself must still + // advance that lifecycle instead of aborting graph execution. + match operation { + crate::graph_host::motion_stock_gameplay::Operation::MatchAirTime => { + let Instance::MatchAirTime(state) = instance else { + return Err("MatchAirTime operation/instance mismatch".into()); + }; + if phase != 2 { + let p = host.gameplay_conditions + .ok_or("MatchAirTime requires completed OffBoard output")?; + if phase == 0 { + state.begin(host.offboard_cadence_phase + .ok_or("MatchAirTime requires completed OffBoard80")?, + p.offboard_air_scalar_92, p.offboard_air_translation); + } else { + state.update(&mut host.animation, p.offboard_time_to_land, + p.offboard_air_scalar_92, p.offboard_air_translation); + } + } + } + crate::graph_host::motion_stock_gameplay::Operation::InitMovingObjects { path } => { + if phase == 0 { + host.moving_objects.register(path); + } + } + crate::graph_host::motion_stock_gameplay::Operation::MovingObject { path } => { + match phase { + 0 => host.moving_objects.begin(&path)?, + 1 => { + let active = host.moving_objects.update(&path)?; + if !active { + return Err(format!("MovingObject is not active: {path}")); + } + } + _ => host.moving_objects.end(), + } + } + crate::graph_host::motion_stock_gameplay::Operation::LandOnBoard => { + //82BB95D8 -> FlipBoardBackwards82B97150. End/Update are empty. + if phase == 0 && host.gameplay_conditions.as_ref() + .ok_or("LandOnBoard requires completed physical output")?.landing_turning { + *host.animation.skater_animation_flags.as_mut() + .ok_or("LandOnBoard requires SkaterAnim orientation")? ^= 0x80000000; + } + } + crate::graph_host::motion_stock_gameplay::Operation::UpdateStandingOnCar => { + // This node is a consumer of the moving-object + // publication. It must not turn an absent publication + // into a fatal graph error: the native dispatcher keeps + // the last valid physical owner state and continues the + // frame. A moving-object owner can publish its update + // through the registry when one is actually active. + let _ = phase; + } + crate::graph_host::motion_stock_gameplay::Operation::SetGrabType { grab } => { + let grab = crate::graph_host::motion_stock_gameplay::GrabType::parse(&grab)?; + if phase == 0 { + host.animation.set_grab_type(grab); + } else if phase == 2 { + host.animation.clear_grab_type(); + } + } + crate::graph_host::motion_stock_gameplay::Operation::ScoringGrabs { + grab_name, up, left, down, right, intent_x, intent_y, invert_y, polar, + } => { + if phase == 1 { + // TU3 82BBEF60 publishes a score name and vector. + // It never starts animations or changes channel influence. + let first = host.animation.filtered_intent(&intent_x).unwrap_or(0.0); + let second = host.animation.filtered_intent(&intent_y).unwrap_or(0.0); + let (x, y) = if polar { + let x = second * first.sin(); + let y = second * first.cos(); + (x, if invert_y { -y } else { y }) + } else { + (first, second) + }; + let selected = crate::graph_host::motion_stock_gameplay::select_grab_score( + &grab_name, [up.as_deref(), left.as_deref(), down.as_deref(), right.as_deref()], x, y, + ); + host.score_packet.grab = Some((encode(selected.as_bytes()), [x, y])); + } + } + crate::graph_host::motion_stock_gameplay::Operation::TweakProject { + intent_x, intent_y, attribute_x, attribute_y, + } => { + if phase == 1 { + // TU3 82BABFD0/82BABFFC read the same slot+8 map + // written by FilterMotionGraphIntent at 82BB19D0. + // This is filtered output, NOT raw AG-produced MG + // input. Bypassing it removes authored tweak timing. + let source_x = host.animation.filtered_intent(&intent_x); + let source_y = host.animation.filtered_intent(&intent_y); + if source_x.is_none() && source_y.is_none() { + return Ok(()); + } + let mut x = source_x.unwrap_or(0.0); + let mut y = source_y.unwrap_or(0.0); + let total = x.abs() + y.abs(); + if total > 1.0 { + let scale = 1.0 / total; + x *= scale; + y *= scale; + } + host.animation.set_attribute(SettableAttribute { + name: attribute_x, + value: x, + normalized: false, + sequence_id: -1, + }); + host.animation.set_attribute(SettableAttribute { + name: attribute_y, + value: y, + normalized: false, + sequence_id: -1, + }); + } + } + crate::graph_host::motion_stock_gameplay::Operation::JumpInto { attribute } => { + let Instance::JumpInto { first_update } = instance else { + return Err("JumpInto instance was not allocated".into()); + }; + //8231FBDC Begin/End are empty. Update82BACDC0 consumes + //the instance latch after the query, including a miss. + if phase == 1 && *first_update { + host.animation.jump_into(attribute)?; + *first_update = false; + } + } + crate::graph_host::motion_stock_gameplay::Operation::SetManualAngle { attribute } => { + let state = match instance { + Instance::SetManualAngle(state) => state, + _ => return Err("SetManualAngle instance was not allocated".into()), + }; + match phase { + 0 => state.begin(), + 1 => { + let value = state.update(host.animation.motion_intent("Manual"), &host.manual); + host.animation.set_attribute(SettableAttribute { + name: attribute, + value, + normalized: false, + sequence_id: -1, + }); + } + // Vtable8231F88C End points to empty82B61BB8. + _ => {} + } + } + crate::graph_host::motion_stock_gameplay::Operation::HippyJumpAntic => { + let state = match instance { + Instance::HippyJumpAntic(state) => state, + _ => return Err("HippyJumpAntic instance was not allocated".into()), + }; + match phase { + 0 => { + state.begin(); + } + 1 => state.update(&mut host.animation, &host.hippy_jump), + 2 => { + state.end(); + } + _ => unreachable!(), + } + } + other => { + return Err(format!( + "MotionGraph stock gameplay producer {other:?} is not implemented" + )); + } + } + } + (MotionOperation::SettingBodyTilt(name), Instance::BodyTilt(instance)) => { + if phase == 1 && host.applying_body_tilt { + if let Some(value) = instance.update( + true, + host.playback_context + .is_mirrored + .ok_or("SettingBodyTilt needs actual mirrored stance")?, + host.body_tilt_physical + .ok_or("SettingBodyTilt needs actual PhysOut tilt and spin")?, + &host.body_tilt, + ) { + host.animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } else if phase == 1 { + // Disabled native branch updates the enable latch without + // reading physics or emitting a parameter. + instance.disable(); + } + } + (MotionOperation::Play(operation), Instance::Play(instance)) => match phase { + 0 => instance.begin(&operation, &mut host.playback_context, &mut host.animation)?, + 1 => instance.update(&operation, &mut host.animation)?, + _ => instance.end(), + }, + ( + MotionOperation::AttachIntent { + intent, + attribute, + set, + }, + _, + ) => { + if phase == 1 { + host.animation.attach(&intent, attribute, set); + } + } + (MotionOperation::Riding(operation), _) => host.riding.execute( + operation, + phase, + frame, + &mut host.animation, + host.condition_inputs + .physical_state + .as_ref() + .map(|p| p.category), + host.crouching_physical.map(|p| p.animation_height_72), + host.flags.manualing, + )?, + (MotionOperation::ApplyingBodyTilt, _) => { + if phase == 0 { + host.applying_body_tilt = true; + } else if phase == 2 { + host.applying_body_tilt = false; + } + } + (MotionOperation::Crouching(name), Instance::Crouching(state)) => { + use skate_core::animation::crouching; + if phase == 0 { + *state = Some(crouching::State::begin( + host.crouching_physical + .ok_or("Crouching Begin requires real physical height and speed")?, + &host.crouching, + )); + } else if phase == 1 { + let intents = crouching::Intents { + auto_pump_angle: host.animation.motion_intent("AutoPumpAngle"), + auto_pump_magnitude: host.animation.motion_intent("AutoPumpMag"), + crouch: host.animation.motion_intent("Crouch"), + hard_turn_crouch: host.animation.motion_intent("HardTurnCrouch"), + manual: host.animation.motion_intent("Manual"), + motion_flag_108: host.is_power_sliding, + }; + let result = state + .as_mut() + .ok_or("Crouching updated before Begin")? + .update( + host.crouching_physical + .ok_or("Crouching requires actual physical outputs")?, + intents, + frame.dt, + &host.crouching, + ); + if result.new_auto_pump { + host.animation.emit_packet(encode(b"NewAutoPump"), 1.0); + } + if result.player_controlled_pump { + host.animation + .emit_packet(encode(b"PlayerControlledPump"), 1.0); + } + host.animation.set_attribute(SettableAttribute { + name, + value: result.height, + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::SetTurning(names), Instance::Turning(instance)) => { + if phase == 0 { + instance.enter(); + } else if phase == 1 { + let physical = host + .physical + .ok_or("SetTurning requires actual PhysOutAnimation and stance outputs")?; + let intents = set_turning::Intents { + fakie_turn: host.animation.motion_intent("FakieTurn"), + mode_0_slide: host.animation.motion_intent("LeftSlide"), + mode_1_slide: host.animation.motion_intent("RightSlide"), + }; + //ISkaterAnim v12=82B970D8/v28=82B97140 read live bits29/30. + //UpdateRidingFakie may have changed them earlier in this graph tick. + let flags = host + .animation + .skater_animation_flags + .ok_or("SetTurning requires actual animation stance flags")?; + set_turning::update( + instance, + &mut host.slide_latch, + physical.turning, + (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), + frame.dt, + &host.turning, + intents, + |attribute, value| { + let index = match attribute { + set_turning::Attribute::Angle => Some(0), + set_turning::Attribute::Direction => Some(1), + set_turning::Attribute::Quickness => Some(2), + set_turning::Attribute::Speed => Some(3), + set_turning::Attribute::Holding => Some(4), + _ => None, + }; + if let Some(index) = index { + host.animation.set_attribute(SettableAttribute { + name: names[index], + value, + normalized: false, + sequence_id: -1, + }); + } else { + host.animation.emit_packet( + encode(if attribute == set_turning::Attribute::Turn { + b"Turn" + } else { + b"Slide" + }), + value, + ); + } + }, + ); + } + } + (MotionOperation::Push(_), Instance::Push(instance)) => { + let physical = host + .physical + .ok_or("Push behavior requires actual skater physical outputs")?; + // The attribute sink borrows animation mutably during this + // callback; retain a snapshot of the same canonical intent map. + let intents = host.animation.motion_intents.clone(); + let mut context = PushContext { + settings: &host.pushing, + shared: host + .push_state + .as_mut() + .ok_or("Native push state initialization has not been published")?, + motion_intents: &intents, + forward_speed: physical.forward_speed, + delta_seconds: frame.dt, + time_since_teleport: host.riding.time_since_teleport, + is_switch: physical.is_switch, + foot_frame: physical.foot_frame, + }; + match phase { + 0 => instance.begin(&mut context, &mut host.animation), + 1 => instance.update(&mut context, &mut host.animation)?, + _ => instance.end(&mut context), + } + } + (MotionOperation::Unsupported { kind, name }, _) => { + return Err(format!("Unsupported MotionGraph {kind:?} {name}")); + } + (operation, _) => { + return Err(format!( + "Invalid MotionGraph behavior instance {operation:?}" + )); + } + } + + Ok(()) +} diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs index d85a2272..984a2061 100644 --- a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -16,7 +16,6 @@ pub struct GameplayConditions { pub retrieving_board: bool, pub dropping_board: bool, pub in_biped_air: bool, - pub can_land_on_board: bool, pub hippy_hurdling: bool, pub handplant_flags: u32, pub handplant_time: f32, @@ -27,22 +26,48 @@ pub struct GameplayConditions { pub footplant_contact_time: f32, pub time_to_skitch: f32, pub skitch_transition_time: f32, - /// TimeToLand82BA7250 reads Air184 when Air437 is valid. + /// TimeToLand82BA7250: PhysOutAir+184, gated by byte437. pub time_to_land: f32, pub time_to_land_valid: bool, - pub trucks_or_deck_contact: bool, - /// PhysOutOffBoard+32, shared by OBTimeToLand and OBTrajTime. + /// OBTimeToLand82BA5770: PhysOutOffBoard+32. pub offboard_time_to_land: f32, + /// OffboardBodyTweakBlend82BBAFC8 reads completed OffBoard+92. + pub offboard_air_scalar_92: f32, + /// MatchAirTime82BBA200 consumes the completed OffBoard+96 vector. + pub offboard_air_translation: [f32; 4], + /// CanBipedLand82BA8214 tests OffBoard+192.Y strictly above0.85. + pub offboard_landing_normal: [f32; 4], + ///IsBipedCommittedToMotion82BA80A0: completed OffBoard byte329. + pub offboard_committed_to_motion: bool, + ///EnoughDistToObstacle82BA7FD0: completed OffBoard+112, not distance116. + pub offboard_obstacle_distance: f32, + ///DistToEdge82BA82A8: completed OffBoard+116, not obstacle scalar112. + pub offboard_edge_distance: f32, + /// OBTrajTime82BA4528: PhysOutOffBoard+120, gated by byte331. + pub offboard_trajectory_time: f32, + pub offboard_trajectory_valid: bool, + /// AirOutputFields::reached_apex_436, published by the known-air owner. + pub reached_apex: bool, + ///TU3 CanLandOnBoard82BA5D30 and LandOnBoard82BB95D8. + pub can_land_on_board: bool, + pub landing_turning: bool, + /// GrindOutputFields::flag_318/322, published by the contact solver. + pub grind_contact: bool, + /// CollisionOutputFields::wheel_contact_3296_3299. + pub wheel_contact: bool, + /// Collision3472 (trucks) OR3475 (deck), excluding wheels. + pub trucks_or_deck_contact: bool, + /// StateGraph's moving-object state publication. TU3 camera and motion + /// consumers use state 502 for the moving-object riding branch. + pub moving_object: bool, pub tricks_blocked_on_stairs: bool, } #[derive(Clone, Debug, PartialEq)] pub enum GameplayCondition { - DebugAnimations, RetrievingBoard, DroppingBoard, InBipedAir, - CanLandOnBoard, HippyHurdling, WantsRunout, PhysicsWiping, @@ -62,16 +87,15 @@ pub enum GameplayCondition { PlayHandplant { phase: usize }, EnteringSkitch, Skitching, + IsMovingObject, } impl GameplayCondition { pub fn recognizes(name: &str) -> bool { matches!( name, "IsRetrievingSkateboard" - | "IsInDebugAnimationsMode" | "IsDroppingSkateboard" | "IsInBipedAir" - | "CanLandOnBoard" | "IsHippyHurdling" | "PhysicsWantsRunout" | "IsPhysicsWiping" @@ -91,15 +115,14 @@ impl GameplayCondition { | "ShouldPlayHandPlantAnim" | "IsEnteringSkitch" | "IsSkitching" + | "IsMovingObject" ) } pub fn parse(a: &Attributes<'_>) -> Result { Ok(match a.text("name").unwrap_or("") { - "IsInDebugAnimationsMode" => Self::DebugAnimations, "IsRetrievingSkateboard" => Self::RetrievingBoard, "IsDroppingSkateboard" => Self::DroppingBoard, "IsInBipedAir" => Self::InBipedAir, - "CanLandOnBoard" => Self::CanLandOnBoard, "IsHippyHurdling" => Self::HippyHurdling, "PhysicsWantsRunout" => Self::WantsRunout, "IsPhysicsWiping" => Self::PhysicsWiping, @@ -123,6 +146,7 @@ impl GameplayCondition { Some("antic") => 2, Some("into") => 1, Some("out") => 0, value => return Err(format!("Unauthored handplant animation phase {value:?}")), } }, + "IsMovingObject" => Self::IsMovingObject, "IsHandPlanting" => Self::HandPlanting { //82BA54A0 compares these authored strings in this order. state: match a.text("state").unwrap_or("") { @@ -143,20 +167,18 @@ impl GameplayCondition { /// conditions that also require the MotionGraph/channel owner. pub fn evaluate_physical(&self, p: &GameplayConditions) -> Option { Some(match self { - //Factory82BC3F68, VT8231ED5C slot48=8274CA90 (li r3,0;blr). - Self::DebugAnimations => false, - Self::OkToDoTrickOnStairs => !p.tricks_blocked_on_stairs, //82BA6930:Animation166 - Self::RetrievingBoard => p.retrieving_board, //82BA5EF0:Offboard323 - Self::InBipedAir => p.in_biped_air, //82BA8030:Offboard328 - Self::CanLandOnBoard => p.can_land_on_board, //82BA5D30:Offboard316 - Self::HippyHurdling => p.hippy_hurdling, //82BA5DA0:Offboard317 - Self::WantsRunout => p.wants_runout, //82BA44B8:State78 - Self::PhysicsWiping => p.physics_wiping, //82BA4390:State59 - Self::BodyFlipping => p.body_flipping, //82BA71E0:Air441 - Self::WantsWipeout => p.wants_wipeout, //82BA4400:State63 || State65 + Self::OkToDoTrickOnStairs => !p.tricks_blocked_on_stairs, + Self::RetrievingBoard => p.retrieving_board, //82BA5EF0:Offboard323 + Self::InBipedAir => p.in_biped_air, //82BA8030:Offboard328 + Self::HippyHurdling => p.hippy_hurdling, //82BA5DA0:Offboard317 + Self::WantsRunout => p.wants_runout, //82BA44B8:State78 + Self::PhysicsWiping => p.physics_wiping, //82BA4390:State59 + Self::BodyFlipping => p.body_flipping, //82BA71E0:Air441 + Self::WantsWipeout => p.wants_wipeout, //82BA4400:State63 || State65 Self::Bumped => p.bumped, //82BA7310: published acceleration and anim_motion/bumps Self::GrabbingObject => p.grabbing_object, //82BA5700:Offboard304 Self::Skitching => p.state == 104, //82BBBC88:State16 + Self::IsMovingObject => p.moving_object, Self::EnteringSkitch => { //82BBBDE0:Ground276,Globals400/layout96 !(p.time_to_skitch < 0.0) && p.time_to_skitch <= p.skitch_transition_time diff --git a/crates/skate-game/src/graph_host/motion_grind.rs b/crates/skate-game/src/graph_host/motion_grind.rs index 9c4b0d6d..5b02aca9 100644 --- a/crates/skate-game/src/graph_host/motion_grind.rs +++ b/crates/skate-game/src/graph_host/motion_grind.rs @@ -1,73 +1,83 @@ -//! Stock grind graph behaviors82BAF208/82BB0A30/82BB0D40/82BB10F8. +//! Original S3 grind graph lifecycle, using the current animation owner. +//! No animation choice, physical-state transition or synthetic observation. +pub mod animation; +pub mod conditions; +mod settings; +pub use settings::Settings; +#[cfg(test)] +mod tests; + +use animation::{Animation, endpoints, set}; use skate_core::animation::{ - grind_control::{Fade, FadeSettings, crouch}, - playback_parameters::{AttributeSink, SettableAttribute}, + grind_control::{Fade, crouch, facing, mirrored_twist}, + output::attributes::AttributeName, skeleton_input::name::encode, }; -use skate_data::{collections::Collections, state_graph::attributes::Attributes}; +use skate_data::state_graph::attributes::Attributes; + #[derive(Clone, Debug, PartialEq)] pub enum Operation { Attributes, - Crouch, + Crouch { + height: AttributeName, + }, Fade { - height: String, - twist: String, + height: AttributeName, + twist: AttributeName, intent: String, }, } impl Operation { - pub fn parse(a: &Attributes<'_>) -> Option { - Some(match a.text("name")? { - "CreateGrindAttributes" => Self::Attributes, - "ControlGrindCrouch" => Self::Crouch, - "GrindControlFade" => Self::Fade { - height: a - .text("distBoardToCogAnimAttribute") - .unwrap_or("DistToCog") - .into(), - twist: a.text("twistAnimAttribute").unwrap_or("twist").into(), - intent: a.text("twistMGIntent").unwrap_or("GrindBalanceX").into(), + pub fn parse(a: &Attributes<'_>) -> Result, String> { + let required = |name| { + a.text(name) + .ok_or_else(|| format!("GrindControlFade requires {name}")) + }; + Ok(Some(match a.text("name") { + Some("CreateGrindAttributes") => Self::Attributes, + Some("ControlGrindCrouch") => Self::Crouch { + height: encode(a.text("driveDistToCom").unwrap_or("disttocog").as_bytes()), }, - _ => return None, - }) - } -} -pub struct Settings { - pub fade: FadeSettings, - pub height: [f32; 3], -} -impl Settings { - pub fn load(d: &Collections) -> Result { - let f = |n| d.float("anim_motion", "grind_twist", n); - Ok(Self { - fade: FadeSettings { - response: f("twist_smoothing")?, - input_scale: f("twist_sensitivity")?, - acceleration: f("twist_max_delta_delta")?, - maximum_step: f("twist_max_delta")?, + Some("GrindControlFade") => Self::Fade { + height: encode(required("distBoardToCogAnimAttribute")?.as_bytes()), + twist: encode(required("twistAnimAttribute")?.as_bytes()), + intent: required("twistMGIntent")?.into(), }, - height: [ - d.float("anim_motion", "grind_height", "min_grind_disttocog")?, - d.float("anim_motion", "grind_height", "max_grind_disttocog")?, - d.float("anim_motion", "grind_height", "grind_disttocog_speed")?, - ], - }) + _ => return Ok(None), + })) } } + +/// Per behavior instance, never shared between different graph nodes. #[derive(Default)] pub struct State { - pub fade: Fade, - pub height: f32, + fade: Option, + height: Option, } -#[derive(Clone, Debug, Default)] + +/// Required genuine physical fields. No Default: an unavailable producer must +/// not silently become a fabricated all-zero observation. +#[derive(Clone, Copy, Debug)] pub struct Physical { - pub name: String, + /// Filtered bundle64 byte80 and five-word encoded name at+8. pub grinding: bool, - pub dropping_in: bool, + pub grind_name: AttributeName, + /// Motion bundle4 vector80 (deck velocity), Basic bundle0 vector128 + /// (completed effective board forward). Historical field name retained. + pub ground_axis: [f32; 3], + pub board_axis: [f32; 3], + /// Motion273: Processed2468 bit20, not a ground-contact flag. + pub ground_flag_273: bool, + /// Live animation mirror, not a cached physical stance. + pub animation_mirrored: bool, + /// Animation bundle56 float72, Filtered bundle64 float56. + pub height: f32, pub crouch: f32, + /// RAW Skeleton bundle20 float504; this handler mirrors it on Begin. pub twist: f32, - pub facing_backwards: bool, } + +/// phase0=Begin,1=Update,2=End. Graph execution owns lifecycle/order. pub fn execute( state: &mut State, op: &Operation, @@ -75,54 +85,70 @@ pub fn execute( dt: f32, settings: &Settings, physical: &Physical, - height: f32, - animation: &mut super::motion_animation::MotionAnimation, + animation: &mut impl Animation, ) -> Result<(), String> { - let set = |animation: &mut super::motion_animation::MotionAnimation, name: &str, value: f32| { - animation.set_attribute(SettableAttribute { - name: encode(name.as_bytes()), - value, - normalized: false, - sequence_id: -1, - }); - }; match op { Operation::Attributes if phase == 1 && physical.grinding => { - animation.motion_intents.insert(&physical.name, 1.0); - animation.motion_intents.insert( - if physical.facing_backwards { - "GrindFacingBackwards" + // Actor1800 slot16 ->8258F6E0 AddMgAttribute, NOT InsertIntent. + animation.emit_packet(physical.grind_name, 1.0); + let backwards = facing::backwards( + physical.ground_axis, + physical.board_axis, + physical.ground_flag_273, + physical.animation_mirrored, + ); + animation.emit_packet( + encode(if backwards { + b"GrindFacingBackwards" } else { - "GrindFacingForwards" - }, + b"GrindFacingForwards" + }), 1.0, ); } - Operation::Crouch if phase == 0 => state.height = height, - Operation::Crouch if phase == 1 => { - let intent = animation - .motion_intents - .get("Crouch") - .copied() - .unwrap_or(0.0); + Operation::Crouch { .. } if phase == 0 => state.height = Some(physical.height), + Operation::Crouch { height } if phase == 1 => { + let previous = state + .height + .ok_or("ControlGrindCrouch Update before Begin")?; let [min, max, rate] = settings.height; - state.height = crouch(state.height, intent, physical.crouch, min, max, rate, dt); - set(animation, "DistToCog", state.height); + let next = crouch( + previous, + animation.intent("Crouch").unwrap_or(0.0), + physical.crouch, + min, + max, + rate, + dt, + ); + state.height = Some(next); + set(animation, *height, next, false); } Operation::Fade { - height: name, + height, twist, intent, } => { if phase == 0 { - set(animation, name, height); - let bounds = animation.attribute_endpoints(encode(twist.as_bytes()))?; - let value = state.fade.begin(bounds[0], bounds[1], physical.twist); - set(animation, twist, value); + set(animation, *height, physical.height, false); + let bounds = endpoints(animation, *twist)?; + let mut fade = Fade::default(); + let value = fade.begin( + bounds[0], + bounds[1], + mirrored_twist(physical.twist, physical.animation_mirrored), + ); + set(animation, *twist, value, false); + state.fade = Some(fade); } else if phase == 1 { - let input = animation.motion_intents.get(intent).copied().unwrap_or(0.0); - if let Some(value) = state.fade.update(dt, input, settings.fade) { - set(animation, twist, value); + let fade = state + .fade + .as_mut() + .ok_or("GrindControlFade Update before Begin")?; + if let Some(value) = + fade.update(dt, animation.intent(intent).unwrap_or(0.0), settings.fade) + { + set(animation, *twist, value, false); } } } diff --git a/crates/skate-game/src/graph_host/motion_grind/animation.rs b/crates/skate-game/src/graph_host/motion_grind/animation.rs new file mode 100644 index 00000000..c870b0af --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/animation.rs @@ -0,0 +1,49 @@ +//! Begin82BB0A30: live set/apply/query, including partial output on a miss. +use skate_core::animation::{ + output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, + playback_parameters::{AttributeSink, SettableAttribute}, +}; + +/// Existing animation services plus a per-name query of its CURRENT root. +/// No clone/probe tree, destination override, attribute enumeration or clock step. +pub trait Animation: AttributeSink { + fn apply_parameters(&mut self) -> Result<(), String>; + fn query_current_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result; + fn emit_packet(&mut self, name: AttributeName, value: f32); + fn intent(&self, name: &str) -> Option; +} + +pub(super) fn set( + animation: &mut impl AttributeSink, + name: AttributeName, + value: f32, + normalized: bool, +) { + animation.set_attribute(SettableAttribute { + name, + value, + normalized, + sequence_id: -1, + }); +} + +pub fn endpoints(animation: &mut impl Animation, name: AttributeName) -> Result<[f32; 2], String> { + let mut bounds = [0.0; 2]; + for (i, bound) in bounds.iter_mut().enumerate() { + set(animation, name, i as f32, true); + animation.apply_parameters()?; + let mut attribute = MotionGraphAttribute { name, value: 0.0 }.to_animation(); + // Native ignores the success boolean. Preserve a child's partial write; + // do not reset to zero on false, nor conflate data errors with a miss. + animation.query_current_attribute(name, 15, &mut attribute)?; + *bound = f32::from_bits( + attribute.payload.0[0].ok_or("Grind twist query returned an uninitialized scalar")?, + ); + } + Ok(bounds) +} diff --git a/crates/skate-game/src/graph_host/motion_grind/animation_owner.rs b/crates/skate-game/src/graph_host/motion_grind/animation_owner.rs new file mode 100644 index 00000000..f91a7d0a --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/animation_owner.rs @@ -0,0 +1,37 @@ +//! Compile as a child of motion_animation, NOT motion_grind, so the real +//! owner's private current tree stays private. See the module line in handoff. +use super::MotionAnimation; +use crate::graph_host::motion_grind::animation::Animation; +use skate_core::animation::output::attributes::{AnimationAttribute, AttributeName}; + +impl Animation for MotionAnimation { + fn apply_parameters(&mut self) -> Result<(), String> { + MotionAnimation::apply_parameters(self) + } + + fn query_current_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result { + // S3 virtual48 ->825310E0 returns the current root at owner+13524. + // Query that root, retaining transition routing and partial results. + self.current + .as_ref() + .ok_or("GrindControlFade requires a current animation tree")? + .query_attribute(name, mask, output) + } + + fn emit_packet(&mut self, name: AttributeName, value: f32) { + MotionAnimation::emit_packet(self, name, value); + } + + fn intent(&self, name: &str) -> Option { + self.motion_intents.get(name).copied() + } +} + +#[cfg(test)] +#[path = "animation_owner_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_grind/animation_owner_tests.rs b/crates/skate-game/src/graph_host/motion_grind/animation_owner_tests.rs new file mode 100644 index 00000000..af159780 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/animation_owner_tests.rs @@ -0,0 +1,84 @@ +use super::*; +use crate::graph_host::motion_grind::animation::endpoints; +use skate_core::animation::{ + output::attributes::MotionGraphAttribute, + phase_blend::PhaseBlend, + playback_clip::{ClipAttribute, PlaybackClip}, + playback_parameters::{AttributeSink, SettableAttribute}, + playback_tree::PlaybackTree, + skeleton_input::name::encode, +}; +use skate_data::animation_metadata::AnimationMetadata; + +fn owner() -> MotionAnimation { + let metadata = AnimationMetadata::parse( + r#"{ + "version":1,"source_bank":"synthetic-test", + "source_sha256":"0000000000000000000000000000000000000000000000000000000000000000", + "source_bytes":48,"clips":[],"unsupported_trees":[] + }"#, + ) + .unwrap(); + let mut owner = MotionAnimation::from_metadata(metadata); + let clip = |value: f32| PlaybackTree::Clip { + name: "independent-grind-test".into(), + clip: PlaybackClip::new( + 61.0, + 60.0, + 1.0, + 0, + vec![ClipAttribute { + name: encode(b"twist"), + kind: 0, + begin: -1.0, + end: -1.0, + payload: vec![value.to_bits()], + }], + ), + }; + owner.current = Some(PlaybackTree::PhaseBlend( + PhaseBlend::new(encode(b"twist"), vec![clip(-2.0), clip(4.0)]).unwrap(), + )); + owner +} + +#[test] +fn actual_motion_animation_adapter_uses_live_queue_root_and_packet_owner() { + let mut owner = owner(); + owner.motion_intents.insert("unchanged", 0.25); + // The probe must replace this queued attribute, not append a duplicate + // that an actual PhaseBlend would read first and use for both endpoints. + owner.set_attribute(SettableAttribute { + name: encode(b"twist"), + value: 0.5, + normalized: true, + sequence_id: -1, + }); + assert_eq!( + endpoints(&mut owner, encode(b"twist")).unwrap(), + [-2.0, 4.0] + ); + assert_eq!(owner.pending_parameter(encode(b"twist")), None); + let mut out = MotionGraphAttribute { + name: encode(b"twist"), + value: 0.0, + } + .to_animation(); + assert!( + owner + .query_current_attribute(encode(b"twist"), 15, &mut out) + .unwrap() + ); + assert_eq!(f32::from_bits(out.payload.0[0].unwrap()), 4.0); + Animation::emit_packet(&mut owner, encode(b"GrindFacingForwards"), 1.0); + assert_eq!(owner.motion_attributes.len(), 1); + assert_eq!(owner.motion_intents.len(), 1); + assert_eq!(owner.motion_intents.get("unchanged"), Some(&0.25)); +} + +#[test] +fn actual_owner_missing_tree_is_an_error_not_fabricated_endpoints() { + let mut owner = owner(); + owner.current = None; + assert!(endpoints(&mut owner, encode(b"twist")).is_err()); +} diff --git a/crates/skate-game/src/graph_host/motion_grind/conditions.rs b/crates/skate-game/src/graph_host/motion_grind/conditions.rs new file mode 100644 index 00000000..b2a4fb25 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/conditions.rs @@ -0,0 +1,60 @@ +//! Original S3 graph predicates. Producers and shared dispatch are external. +use skate_core::animation::skeleton_input::name::encode; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Condition { + BluntingBackslash, + Approach { forwards: bool }, + TrickOutType { value: u32 }, + LandingIntoGrind, + DroppingIn, +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name") { + Some("IsGrindBluntingBackslash") => Self::BluntingBackslash, + Some("IsGrindApproach") => Self::Approach { + forwards: encode(a.text("Facing").unwrap_or("F").as_bytes()) == encode(b"F"), + }, + Some("GrindTrickOutTypeAllowed") => Self::TrickOutType { + value: match a.text("type") { + Some("normal") => 0, + Some("left") => 1, + Some("right") => 2, + other => { + return Err(format!("Invalid GrindTrickOutTypeAllowed type: {other:?}")); + } + }, + }, + Some("IsLandingIntoGrind") => Self::LandingIntoGrind, + Some("IsDroppingIn") => Self::DroppingIn, + _ => return Ok(None), + })) + } + + pub fn evaluate(self, p: &Physical) -> bool { + match self { + Self::BluntingBackslash => p.blunting_136 == 4, //82BBDC08 + Self::Approach { forwards } => { + p.filtered_grinding_80 && p.approach_268 == u32::from(forwards) + } //82BBDC80 + Self::TrickOutType { value } => p.trick_out_240 == value, //82BBDA10 + Self::LandingIntoGrind => p.air_grind_443 && p.air_time_184 < 0.2, //82BBDD70 + Self::DroppingIn => p.dropping_in_324, //82BA41F0 + } + } +} + +/// Grinds bundle16 except filtered bundle64 byte80 / Air bundle8 fields. +/// No defaults or inference from contacts, physical state ID, grabs or airborne. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub filtered_grinding_80: bool, + pub blunting_136: u32, + pub approach_268: u32, + pub trick_out_240: u32, + pub air_grind_443: bool, + pub air_time_184: f32, + pub dropping_in_324: bool, +} diff --git a/crates/skate-game/src/graph_host/motion_grind/settings.rs b/crates/skate-game/src/graph_host/motion_grind/settings.rs new file mode 100644 index 00000000..0ec27a03 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/settings.rs @@ -0,0 +1,26 @@ +use skate_core::animation::grind_control::FadeSettings; +use skate_data::collections::Collections; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub fade: FadeSettings, + pub height: [f32; 3], +} +impl Settings { + pub fn load(d: &Collections) -> Result { + let f = |n| d.float("anim_motion", "grind_twist", n); + Ok(Self { + fade: FadeSettings { + response: f("twist_smoothing")?, + input_scale: f("twist_sensitivity")?, + acceleration: f("twist_max_delta_delta")?, + maximum_step: f("twist_max_delta")?, + }, + height: [ + d.float("anim_motion", "grind_height", "min_grind_disttocog")?, + d.float("anim_motion", "grind_height", "max_grind_disttocog")?, + d.float("anim_motion", "grind_height", "grind_disttocog_speed")?, + ], + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_grind/tests.rs b/crates/skate-game/src/graph_host/motion_grind/tests.rs new file mode 100644 index 00000000..fb2784f5 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/tests.rs @@ -0,0 +1,208 @@ +use super::*; +mod conditions; +mod fixture; +mod routing; +use fixture::{Owner, blend, clip, height_dependent_blend}; +use skate_core::animation::{ + grind_control::FadeSettings, playback::TransitionSettings, + playback_transition::PlaybackTransition, playback_tree::PlaybackTree, +}; +use skate_data::state_graph::GraphAttribute; + +fn attributes(pairs: &[(&str, &str)]) -> Vec { + pairs + .iter() + .map(|(name, text)| GraphAttribute { + name: (*name).into(), + text: (*text).into(), + float_bits: 0, + boolean_byte: 0, + }) + .collect() +} +fn settings() -> Settings { + Settings { + height: [0.25, 1.0, 2.0], + fade: FadeSettings { + response: 1.0, + input_scale: 1.0, + acceleration: 0.25, + maximum_step: 0.5, + }, + } +} +fn physical() -> Physical { + Physical { + grinding: true, + grind_name: encode(b"FS_50_50"), + ground_axis: [1.0, 0.0, 0.0], + board_axis: [0.0, 0.0, -1.0], + ground_flag_273: false, + animation_mirrored: false, + height: 1.0, + crouch: 0.75, + twist: 0.0, + } +} +#[test] +fn parser_preserves_authored_crouch_name_and_rejects_missing_fade_attributes() { + let a = attributes(&[ + ("name", "ControlGrindCrouch"), + ("driveDistToCom", "CustomHeight"), + ]); + assert_eq!( + Operation::parse(&Attributes::new(&a)).unwrap(), + Some(Operation::Crouch { + height: encode(b"CustomHeight") + }) + ); + let a = attributes(&[("name", "GrindControlFade")]); + assert!(Operation::parse(&Attributes::new(&a)).is_err()); +} +#[test] +fn creates_ordered_packets_not_intents_or_settable_parameters() { + let mut a = Owner::new(blend()); + let mut state = State::default(); + a.intents.insert("retained", 0.5); + let before = a.intents.clone(); + execute( + &mut state, + &Operation::Attributes, + 0, + 0.1, + &settings(), + &physical(), + &mut a, + ) + .unwrap(); + assert!(a.packets.is_empty()); + execute( + &mut state, + &Operation::Attributes, + 1, + 0.1, + &settings(), + &physical(), + &mut a, + ) + .unwrap(); + assert_eq!( + a.packets + .iter() + .map(|p| (p.name, p.value)) + .collect::>(), + vec![ + (encode(b"FS_50_50"), 1.0), + (encode(b"GrindFacingBackwards"), 1.0) + ] + ); + assert_eq!(a.intents, before); + assert!(a.pending.entries().is_empty()); + let mut p = physical(); + p.grinding = false; + execute( + &mut state, + &Operation::Attributes, + 1, + 0.1, + &settings(), + &p, + &mut a, + ) + .unwrap(); + assert_eq!(a.packets.len(), 2); +} +#[test] +fn crouch_begins_from_physical_height_updates_authored_parameter_and_ends_silently() { + let mut a = Owner::new(blend()); + let mut state = State::default(); + let op = Operation::Crouch { + height: encode(b"custom"), + }; + assert!(execute(&mut state, &op, 1, 0.125, &settings(), &physical(), &mut a).is_err()); + execute(&mut state, &op, 0, 0.125, &settings(), &physical(), &mut a).unwrap(); + assert!(a.pending.entries().is_empty()); + execute(&mut state, &op, 1, 0.125, &settings(), &physical(), &mut a).unwrap(); + let entry = a.pending.entries()[0]; + assert_eq!( + (entry.name, entry.value, entry.normalized, entry.sequence_id), + (encode(b"custom"), 0.75, false, -1) + ); + a.pending.clear(); + execute(&mut state, &op, 2, 0.125, &settings(), &physical(), &mut a).unwrap(); + assert!(a.pending.entries().is_empty()); +} +#[test] +fn fade_begin_applies_height_before_endpoints_and_keeps_live_tree_effects() { + let mut a = Owner::new(height_dependent_blend()); + let mut state = State::default(); + let op = Operation::Fade { + height: encode(b"height"), + twist: encode(b"twist"), + intent: "balance".into(), + }; + let mut p = physical(); + p.twist = 3.0; + execute(&mut state, &op, 0, 0.1, &settings(), &p, &mut a).unwrap(); + assert_eq!(a.applications, 2); + let f = state.fade.as_ref().unwrap(); + assert_eq!((f.minimum, f.maximum, f.value), (-2.0, 4.0, 3.0)); + assert_eq!(a.pending.entries().len(), 1); + assert!(!a.pending.entries()[0].normalized); + a.apply_parameters().unwrap(); + let mut out = skate_core::animation::output::attributes::MotionGraphAttribute { + name: encode(b"twist"), + value: 0.0, + } + .to_animation(); + assert!( + a.query_current_attribute(encode(b"twist"), 15, &mut out) + .unwrap() + ); + assert_eq!(f32::from_bits(out.payload.0[0].unwrap()), 3.0); + execute(&mut state, &op, 1, 0.1, &settings(), &p, &mut a).unwrap(); + assert!( + a.pending.entries().is_empty(), + "first Update emitted a new value" + ); +} +#[test] +fn endpoint_miss_keeps_original_zero_or_partial_output() { + let mut missing = Owner::new(clip(None, 0.0)); + assert_eq!( + animation::endpoints(&mut missing, encode(b"twist")).unwrap(), + [0.0, 0.0] + ); + let mut partial = Owner::new(PlaybackTree::PhaseBlend( + skate_core::animation::phase_blend::PhaseBlend::new( + encode(b"height"), + vec![clip(Some(-2.0), 0.0), clip(None, 1.0)], + ) + .unwrap(), + )); + assert_eq!( + animation::endpoints(&mut partial, encode(b"twist")).unwrap(), + [-2.0, -2.0] + ); +} +#[test] +fn endpoint_root_query_respects_sequence_vs_blend_without_forcing_destination() { + for (kind, expected) in [(2, [-2.0, 4.0]), (4, [-9.0, -9.0])] { + let transition = PlaybackTransition::new( + clip(Some(-9.0), 0.0), + blend(), + TransitionSettings { + kind, + seconds: 0.5, + under: 0, + matching: 0, + use_channels_from_weights: false, + }, + ); + let mut a = Owner::new(PlaybackTree::Transition(transition)); + assert_eq!( + animation::endpoints(&mut a, encode(b"twist")).unwrap(), + expected + ); + } +} diff --git a/crates/skate-game/src/graph_host/motion_grind/tests/conditions.rs b/crates/skate-game/src/graph_host/motion_grind/tests/conditions.rs new file mode 100644 index 00000000..12b6f08b --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/tests/conditions.rs @@ -0,0 +1,50 @@ +use super::super::conditions::{Condition, Physical}; +use super::attributes; +use skate_data::state_graph::attributes::Attributes; +fn physical() -> Physical { + Physical { + filtered_grinding_80: false, + blunting_136: 4, + approach_268: 1, + trick_out_240: 2, + air_grind_443: true, + air_time_184: 0.125, + dropping_in_324: true, + } +} +#[test] +fn conditions_use_their_actual_fields_not_a_shared_grinding_proxy() { + let mut p = physical(); + assert!(Condition::BluntingBackslash.evaluate(&p)); + assert!(Condition::TrickOutType { value: 2 }.evaluate(&p)); + assert!(Condition::LandingIntoGrind.evaluate(&p)); + assert!(Condition::DroppingIn.evaluate(&p)); + assert!(!Condition::Approach { forwards: true }.evaluate(&p)); + p.filtered_grinding_80 = true; + assert!(Condition::Approach { forwards: true }.evaluate(&p)); + assert!(!Condition::Approach { forwards: false }.evaluate(&p)); + p.approach_268 = 0; + assert!(Condition::Approach { forwards: false }.evaluate(&p)); + p.air_time_184 = 0.2; + assert!(!Condition::LandingIntoGrind.evaluate(&p)); + p.air_time_184 = 0.0; + p.air_grind_443 = false; + assert!(!Condition::LandingIntoGrind.evaluate(&p)); +} +#[test] +fn approach_case_folds_facing_but_trick_out_type_uses_exact_strings() { + let a = attributes(&[("name", "IsGrindApproach"), ("Facing", "f")]); + assert_eq!( + Condition::parse(&Attributes::new(&a)).unwrap(), + Some(Condition::Approach { forwards: true }) + ); + for (name, value) in [("normal", 0), ("left", 1), ("right", 2)] { + let a = attributes(&[("name", "GrindTrickOutTypeAllowed"), ("type", name)]); + assert_eq!( + Condition::parse(&Attributes::new(&a)).unwrap(), + Some(Condition::TrickOutType { value }) + ); + } + let a = attributes(&[("name", "GrindTrickOutTypeAllowed"), ("type", "Left")]); + assert!(Condition::parse(&Attributes::new(&a)).is_err()); +} diff --git a/crates/skate-game/src/graph_host/motion_grind/tests/fixture.rs b/crates/skate-game/src/graph_host/motion_grind/tests/fixture.rs new file mode 100644 index 00000000..59787e74 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/tests/fixture.rs @@ -0,0 +1,104 @@ +//! Independent synthetic data, using the genuine core playback pipeline. +use super::super::animation::Animation; +use skate_core::{ + animation::{ + output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, + phase_blend::PhaseBlend, + playback_clip::{ClipAttribute, PlaybackClip}, + playback_parameters::{AttributeSink, SettableAttribute, SettableAttributes}, + playback_tree::PlaybackTree, + skeleton_input::name::encode, + }, + graph::intents::IntentMap, +}; + +pub struct Owner { + pub tree: PlaybackTree, + pub pending: SettableAttributes, + pub packets: Vec, + pub intents: IntentMap, + pub applications: usize, +} +impl Owner { + pub fn new(tree: PlaybackTree) -> Self { + Self { + tree, + pending: SettableAttributes::default(), + packets: vec![], + intents: IntentMap::new(), + applications: 0, + } + } +} +impl AttributeSink for Owner { + fn set_attribute(&mut self, a: SettableAttribute) { + self.pending.set_attribute(a); + } +} +impl Animation for Owner { + fn apply_parameters(&mut self) -> Result<(), String> { + self.tree.set_attributes(self.pending.entries())?; + self.pending.clear(); + self.applications += 1; + Ok(()) + } + fn query_current_attribute( + &self, + name: AttributeName, + mask: u32, + out: &mut AnimationAttribute, + ) -> Result { + self.tree.query_attribute(name, mask, out) + } + fn emit_packet(&mut self, name: AttributeName, value: f32) { + self.packets.push(MotionGraphAttribute { name, value }); + } + fn intent(&self, name: &str) -> Option { + self.intents.get(name).copied() + } +} +pub fn clip(twist: Option, height: f32) -> PlaybackTree { + let mut attributes = vec![ClipAttribute { + name: encode(b"height"), + kind: 0, + begin: -1.0, + end: -1.0, + payload: vec![height.to_bits()], + }]; + if let Some(value) = twist { + attributes.push(ClipAttribute { + name: encode(b"twist"), + kind: 0, + begin: -1.0, + end: -1.0, + payload: vec![value.to_bits()], + }); + } + PlaybackTree::Clip { + name: "independent-test".into(), + clip: PlaybackClip::new(61.0, 60.0, 1.0, 0, attributes), + } +} +pub fn blend() -> PlaybackTree { + PlaybackTree::PhaseBlend( + PhaseBlend::new( + encode(b"twist"), + vec![clip(Some(-2.0), 0.0), clip(Some(4.0), 0.0)], + ) + .unwrap(), + ) +} +pub fn height_dependent_blend() -> PlaybackTree { + let branch = |a, b| { + PlaybackTree::PhaseBlend( + PhaseBlend::new( + encode(b"height"), + vec![clip(Some(a), 0.0), clip(Some(b), 1.0)], + ) + .unwrap(), + ) + }; + PlaybackTree::PhaseBlend( + PhaseBlend::new(encode(b"twist"), vec![branch(-1.0, -2.0), branch(2.0, 4.0)]).unwrap(), + ) +} diff --git a/crates/skate-game/src/graph_host/motion_grind/tests/routing.rs b/crates/skate-game/src/graph_host/motion_grind/tests/routing.rs new file mode 100644 index 00000000..8fadf09b --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_grind/tests/routing.rs @@ -0,0 +1,73 @@ +use super::attributes; +use crate::graph_host::{ + motion_conditions::MotionCondition, + motion_nodes::{MotionFactory, MotionOperation}, +}; +use skate_data::state_graph::{ + attributes::Attributes, + binding::{Node, OperationFactory, OperationKind}, +}; + +#[test] +fn production_factory_routes_three_handlers_and_all_five_conditions_to_grind_owner() { + let mut factory = MotionFactory; + for name in [ + "CreateGrindAttributes", + "ControlGrindCrouch", + "GrindControlFade", + ] { + let a = attributes(&[ + ("name", name), + ("distBoardToCogAnimAttribute", "height"), + ("twistAnimAttribute", "twist"), + ("twistMGIntent", "GrindBalanceX"), + ]); + assert!(matches!( + factory + .create( + OperationKind::Behavior, + Node::State(0), + &Attributes::new(&a) + ) + .unwrap(), + Some(MotionOperation::Grind(_)) + )); + assert!(!crate::graph_host::motion_stock_gameplay::Operation::recognizes(name)); + } + for name in [ + "IsGrindBluntingBackslash", + "IsGrindApproach", + "GrindTrickOutTypeAllowed", + "IsLandingIntoGrind", + "IsDroppingIn", + ] { + let a = attributes(&[("name", name), ("type", "right"), ("Facing", "B")]); + assert!(matches!( + factory + .create( + OperationKind::Condition, + Node::State(0), + &Attributes::new(&a) + ) + .unwrap(), + Some(MotionOperation::Condition(MotionCondition::Grind(_))) + )); + assert!(!crate::graph_host::motion_stock_conditions::Condition::recognizes(name)); + } +} + +#[test] +fn production_factory_preserves_required_grind_attribute_errors() { + let mut factory = MotionFactory; + for (name, kind) in [ + ("GrindControlFade", OperationKind::Behavior), + ("GrindTrickOutTypeAllowed", OperationKind::Condition), + ] { + let a = attributes(&[("name", name)]); + assert!( + factory + .create(kind, Node::State(0), &Attributes::new(&a)) + .is_err() + ); + } +} diff --git a/crates/skate-game/src/graph_host/motion_ground_slope.rs b/crates/skate-game/src/graph_host/motion_ground_slope.rs index 8145bbec..b4236bfe 100644 --- a/crates/skate-game/src/graph_host/motion_ground_slope.rs +++ b/crates/skate-game/src/graph_host/motion_ground_slope.rs @@ -1,7 +1,7 @@ //! GroundSlopeType: TU3 ctor82BA7BD8 and condition82BA7E80. use skate_data::state_graph::attributes::Attributes; -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct GroundSlopeType(u32); impl GroundSlopeType { diff --git a/crates/skate-game/src/graph_host/motion_hippy_jump.rs b/crates/skate-game/src/graph_host/motion_hippy_jump.rs new file mode 100644 index 00000000..f877783f --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hippy_jump.rs @@ -0,0 +1,100 @@ +//! Owned implementation of `Sk8::Behaviours::HippyJumpAntic::Update`. +//! +//! Skate 3 TU3 IDA verifies the complete producer path: factory `0x82F8A340` +//! registers constructor `0x82BCBF60`; vtable `0x82321414` creates a private +//! elapsed-time record in `0x82BBCB50` and dispatches Update `0x82BBE900`. +//! Update advances that record by exactly 1/60 second, evaluates +//! `anim_motion/hippy_flip/antic_length_to_hippy_height`, and writes the +//! constructor-bound `TrickHeight` attribute. Skate 2 `0x82CA80F8` is only +//! corroborating evidence for the same observable algorithm. + +use skate_core::animation::skeleton_input::name::encode; +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; + +use super::motion_animation::MotionAnimation; +use skate_core::animation::playback_parameters::{AttributeSink, SettableAttribute}; + +pub(super) struct Settings { + pub antic_length_to_hippy_height: PointGraph<8>, +} + +impl Settings { + pub(super) fn load(data: &Collections) -> Result { + let words = + data.words::<20>("anim_motion", "hippy_flip", "antic_length_to_hippy_height")?; + Ok(Self { + antic_length_to_hippy_height: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }, + }) + } +} + +#[derive(Default)] +pub(super) struct State { + active: bool, + elapsed: f32, +} + +impl State { + pub(super) fn begin(&mut self) { + self.active = true; + self.elapsed = 0.0; + } + + pub(super) fn update(&mut self, animation: &mut MotionAnimation, settings: &Settings) { + debug_assert!(self.active); + self.elapsed += 1.0 / 60.0; + animation.set_attribute(SettableAttribute { + name: encode(b"TrickHeight"), + value: settings.antic_length_to_hippy_height.evaluate(self.elapsed), + normalized: false, + sequence_id: -1, + }); + } + + pub(super) fn end(&mut self) { + self.active = false; + self.elapsed = 0.0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn animation() -> MotionAnimation { + MotionAnimation::from_metadata( + skate_data::animation_metadata::AnimationMetadata::parse( + r#"{"version":1,"source_bank":"test-only","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"#, + ) + .unwrap(), + ) + } + + #[test] + fn tu3_update_uses_fixed_tick_and_exact_attribute_case() { + let settings = Settings { + antic_length_to_hippy_height: PointGraph { + x: std::array::from_fn(|i| i as f32), + y: std::array::from_fn(|i| i as f32 * 2.0), + }, + }; + let mut state = State::default(); + let mut animation = animation(); + + state.begin(); + state.update(&mut animation, &settings); + + assert_eq!(state.elapsed.to_bits(), (1.0f32 / 60.0).to_bits()); + let written = animation + .pending_parameter(encode(b"TrickHeight")) + .expect("TU3 HippyJumpAntic must publish TrickHeight"); + assert!((written - 2.0 / 60.0).abs() < 1.0e-6); + // Native encoding823C3C78 folds ASCII case, so this spelling aliases + // the constructor's literal rather than identifying another output. + assert_eq!(encode(b"TrickHeight"), encode(b"trickheight")); + } +} diff --git a/crates/skate-game/src/graph_host/motion_instances.rs b/crates/skate-game/src/graph_host/motion_instances.rs new file mode 100644 index 00000000..724e36b6 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_instances.rs @@ -0,0 +1,105 @@ +//! Per-node allocation retained by the production MotionHost. +use super::*; +pub(super) enum Instance { + Grind(super::super::motion_grind::State), + Trick(i32), + Stateless, + MatchCadence(super::super::motion_offboard_cadence::MatchCadence), + ToggleBoard(skate_core::player::offboard::toggle_board::State), + Runout(super::super::motion_runout::State), + IntentFilter(skate_core::animation::intent_filter::State), + Landing(super::super::motion_landing::State), + Wipeout(super::super::motion_wipeout::State), + OffboardBodyTweak(body_tweak::State), + MatchAirTime(match_air_time::State), + TwistLean(skate_core::player::offboard::twist_lean::State), + Play(PlayAnimationInstance), + Push(PushInstance), + Turning(set_turning::State), + Crouching(Option), + BodyTilt(skate_core::animation::body_tilt::State), + RidingFakie(skate_core::animation::riding_fakie::State), + Pumping(skate_core::animation::pumping_channel::State), + FakieHead(super::super::motion_channels::FakieHead), + LandingData(super::super::motion_native::LandingData), + Sliding(super::super::motion_sliding::State), + SlideDeceleration(f32), + KickTurn(skate_core::animation::kickturn::State), + BodySpin(super::super::motion_spin::State), + AirLeg(skate_core::animation::air_leg_extension::State), + CharacterGesture(super::super::motion_character_gesture::CharacterGesture), + Shove(super::super::motion_shove::ShoveState), + SetManualAngle(skate_core::animation::manual::State), + HippyJumpAntic(super::super::motion_hippy_jump::State), + ///82BB5930 initializes byte8 once per allocated behavior instance. + JumpInto { first_update: bool }, +} +impl Instance { + pub(super) fn new(operation: &MotionOperation) -> Self { + match operation { + MotionOperation::Grind(_) => Self::Grind(Default::default()), + MotionOperation::Trick(_) => Self::Trick(0), + + MotionOperation::ToggleBoard => Self::ToggleBoard(Default::default()), + MotionOperation::Runout(_) => Self::Runout(Default::default()), + MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), + MotionOperation::Landing(_) => Self::Landing(Default::default()), + MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()), + MotionOperation::TwistLean(_) => Self::TwistLean(Default::default()), + MotionOperation::Play(_) => Self::Play(PlayAnimationInstance::default()), + MotionOperation::Push(operation) => Self::Push(PushInstance::new(operation.clone())), + MotionOperation::SetTurning(_) => Self::Turning(set_turning::State { + elapsed: 0.0, + smoothed: 0.0, + mode: 2, + }), + MotionOperation::Crouching(_) => Self::Crouching(None), + MotionOperation::SettingBodyTilt(_) => { + Self::BodyTilt(skate_core::animation::body_tilt::State::default()) + } + MotionOperation::UpdateRidingFakie(_) => { + Self::RidingFakie(skate_core::animation::riding_fakie::State::default()) + } + MotionOperation::Pumping => { + Self::Pumping(skate_core::animation::pumping_channel::State::default()) + } + MotionOperation::BipedCadence => Self::Stateless, + MotionOperation::MatchCadence => Self::MatchCadence(Default::default()), + MotionOperation::OffboardBodyTweakBlend(_) => Self::OffboardBodyTweak(Default::default()), + MotionOperation::StockGameplay(super::super::motion_stock_gameplay::Operation::MatchAirTime) => + Self::MatchAirTime(Default::default()), + MotionOperation::FakieHeadChannel => { + Self::FakieHead(super::super::motion_channels::FakieHead::default()) + } + MotionOperation::Native(super::super::motion_native::Operation::StoreLandingData) => { + Self::LandingData(super::super::motion_native::LandingData::default()) + } + MotionOperation::Slide(super::super::motion_sliding::Operation::Update) => { + Self::Sliding(super::super::motion_sliding::State::default()) + } + MotionOperation::Slide(super::super::motion_sliding::Operation::Deceleration) => { + Self::SlideDeceleration(0.0) + } + MotionOperation::KickTurn(super::super::motion_kickturn::Operation::Steering(_)) => { + Self::KickTurn(skate_core::animation::kickturn::State::new()) + } + MotionOperation::BodySpin => Self::BodySpin(super::super::motion_spin::State::default()), + MotionOperation::AirLeg(_) => Self::AirLeg(Default::default()), + MotionOperation::CharacterGesture => { + Self::CharacterGesture(super::super::motion_character_gesture::CharacterGesture::new()) + } + MotionOperation::Shove(_) => Self::Shove(super::super::motion_shove::ShoveState::new()), + MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::SetManualAngle { .. }, + ) => Self::SetManualAngle(Default::default()), + MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::HippyJumpAntic, + ) => Self::HippyJumpAntic(Default::default()), + MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::JumpInto { .. }, + ) => Self::JumpInto { first_update: true }, + MotionOperation::ResetAnimation(_) => Self::Stateless, + _ => Self::Stateless, + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs index bb6ec285..8fa3e523 100644 --- a/crates/skate-game/src/graph_host/motion_native.rs +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -80,8 +80,9 @@ impl Operation { #[derive(Default)] pub struct ScorePacket { pub handplant: Option<(skate_core::animation::output::attributes::AttributeName, [f32; 2])>, + /// ScoringGrabs 82BBEF60: selected authored name and tweak vector. pub grab: Option<(skate_core::animation::output::attributes::AttributeName, [f32; 2])>, - pub trick: Option, + pub trick_names: super::motion_scoring_trick::Names, pub name: Option, pub flags: u32, } diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index 0745ed85..f07d511b 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -15,15 +15,13 @@ use skate_data::state_graph::{ #[derive(Clone, Debug, PartialEq)] pub enum MotionOperation { Grind(super::motion_grind::Operation), - OffboardAir(super::motion_offboard_air::Operation), - ToggleBoard, - Cadence(super::motion_cadence::Operation), Trick(super::motion_tricks::Operation), + SetDeckPitchAndYaw, Play(PlayAnimation), - JumpInto(AttributeName), ///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8. PrintText2D, - AddRunoutAttribs, + ToggleBoard, + Runout(super::motion_runout::Operation), AttachIntent { intent: String, attribute: AttributeName, @@ -32,12 +30,13 @@ pub enum MotionOperation { Push(PushOperation), /// Angle, direction, quickness, speed, holding, in native emission order. SetTurning([AttributeName; 5]), - SetBumpCoefficients([AttributeName; 2]), ApplyingBodyTilt, SettingBodyTilt(AttributeName), Crouching(AttributeName), - SetManualAngle, Pumping, + BipedCadence, + MatchCadence, + OffboardBodyTweakBlend(super::motion::body_tweak::Settings), DisallowPumping, FakieHeadChannel, Native(super::motion_native::Operation), @@ -46,6 +45,8 @@ pub enum MotionOperation { BodySpin, AirLeg(super::motion_air_leg::Operation), ClearTrickAttr, + ScoringTrick(super::motion_scoring_trick::Operation), + SetBumpCoefficients { x: AttributeName, y: AttributeName }, Landing(super::motion_landing::Operation), Wipeout(super::motion_wipeout::Operation), TwistLean(super::motion_twist_lean::Operation), @@ -53,10 +54,15 @@ pub enum MotionOperation { IntentFilter(super::motion_intent_filter::Operation), CharacterGesture, EndGesture, - StockGameplay(super::motion_stock_gameplay::Operation), Shove(super::motion_shove::ShoveOperation), ResetAnimation(super::motion_reset::Operation), UpdateRidingFakie(skate_core::animation::riding_fakie::Settings), + /// Authored stock operations whose state is owned by the physical/gameplay + /// producers rather than by the animation controller. Keeping these as a + /// typed family lets the authored graph retain its native operation set; + /// execution reports a missing producer instead of silently dropping the + /// node. + StockGameplay(super::motion_stock_gameplay::Operation), SetSpeed { name: AttributeName, value: Option, @@ -82,18 +88,15 @@ impl OperationFactory for MotionFactory { _parent: Node, a: &Attributes<'_>, ) -> Result, String> { - let name = a.text("name").ok_or("MotionGraph operation has no name")?; + let name = a + .text("name") + .ok_or("MotionGraph operation has no name")? + .trim_matches(|c: char| c.is_whitespace() || c == '\0'); let operation = if kind == OperationKind::Condition { MotionCondition::parse(a)?.map(MotionOperation::Condition) } else if kind == OperationKind::Behavior { - if let Some(operation) = super::motion_grind::Operation::parse(a) { + if let Some(operation) = super::motion_grind::Operation::parse(a)? { Some(MotionOperation::Grind(operation)) - } else if let Some(operation) = super::motion_offboard_air::Operation::parse(a) { - Some(MotionOperation::OffboardAir(operation)) - } else if name == "ToggleBoard" { - Some(MotionOperation::ToggleBoard) - } else if let Some(operation) = super::motion_cadence::Operation::parse(a) { - Some(MotionOperation::Cadence(operation)) } else if let Some(operation) = super::motion_landing::Operation::parse(a) { Some(MotionOperation::Landing(operation)) } else if let Some(operation) = super::motion_hand_services::Operation::parse(a) { @@ -112,16 +115,10 @@ impl OperationFactory for MotionFactory { Some(MotionOperation::Shove(operation)) } else { match name { - "SetBumpCoefficients" => Some(MotionOperation::SetBumpCoefficients([ - key(a, "X", ""), - key(a, "Y", ""), - ])), - "ResetSkaterAnimation" | "ResetToGivenStance" => { - Some(MotionOperation::ResetAnimation( - super::motion_reset::Operation::parse(a) - .ok_or("Invalid reset operation")?, - )) - } + "SetDeckPitchAndYaw" => Some(MotionOperation::SetDeckPitchAndYaw), + "ResetSkaterAnimation" | "ResetToGivenStance" => Some(MotionOperation::ResetAnimation( + super::motion_reset::Operation::parse(a).ok_or("Invalid reset operation")?, + )), "MatchTwistAndLean" => Some(MotionOperation::TwistLean( super::motion_twist_lean::Operation::parse(a), )), @@ -134,17 +131,22 @@ impl OperationFactory for MotionFactory { "FilterMotionGraphIntent" => Some(MotionOperation::IntentFilter( super::motion_intent_filter::Operation::parse(a), )), - "JumpInto" => Some(MotionOperation::JumpInto(key(a, "attribute", ""))), - "SetManualAngle" => Some(MotionOperation::SetManualAngle), + "ToggleBoard" => Some(MotionOperation::ToggleBoard), + "AddRunoutAttribs" => super::motion_runout::Operation::parse(a) + .map(MotionOperation::Runout), "PlayAnimation" => Some(MotionOperation::Play(play(a))), "PrintText2D" => Some(MotionOperation::PrintText2D), - "AddRunoutAttribs" => Some(MotionOperation::AddRunoutAttribs), "ApplyingBodyTilt" => Some(MotionOperation::ApplyingBodyTilt), "SettingBodyTilt" => { Some(MotionOperation::SettingBodyTilt(key(a, "tilt_x", "tilt_x"))) } "Crouching" => Some(MotionOperation::Crouching(key(a, "crouchName", ""))), "Pumping" => Some(MotionOperation::Pumping), + "BipedCadence" => Some(MotionOperation::BipedCadence), + "MatchCadence" => Some(MotionOperation::MatchCadence), + "OffboardBodyTweakBlend" => Some(MotionOperation::OffboardBodyTweakBlend( + super::motion::body_tweak::Settings::parse(a), + )), "BodySpin" => Some(MotionOperation::BodySpin), "ControlAirLegExtension" => Some(MotionOperation::AirLeg( super::motion_air_leg::Operation::parse(a), @@ -155,12 +157,18 @@ impl OperationFactory for MotionFactory { | "SetHandPlantAnticLength" | "ScoringHandPlants" | "SetTrickAttr" - | "ScoringTrick" | "MonitorUnderflip" | "SetDark" | "UpdateIsWeightOnNose" => { super::motion_tricks::Operation::parse(a).map(MotionOperation::Trick) } + "ScoringTrick" => Some(MotionOperation::ScoringTrick( + super::motion_scoring_trick::Operation::parse(a)?, + )), + "SetBumpCoefficients" => Some(MotionOperation::SetBumpCoefficients { + x: key(a, "X", "BumpX"), + y: key(a, "Y", "BumpY"), + }), "CharacterGesture" => Some(MotionOperation::CharacterGesture), "EndGesture" => Some(MotionOperation::EndGesture), "DisallowPumping" => Some(MotionOperation::DisallowPumping), @@ -185,10 +193,12 @@ impl OperationFactory for MotionFactory { ), }, )), - // Factory82BC6D40 defaults both identifiers to the string0. name if super::motion_stock_gameplay::Operation::recognizes(name) => Some( - MotionOperation::StockGameplay(super::motion_stock_gameplay::Operation::parse(a)), + MotionOperation::StockGameplay( + super::motion_stock_gameplay::Operation::parse(a), + ), ), + // Factory82BC6D40 defaults both identifiers to the string0. "AttachIntent" => Some(MotionOperation::AttachIntent { intent: a.text("intent").unwrap_or("0").into(), attribute: encode(a.text("attr").unwrap_or("0").as_bytes()), diff --git a/crates/skate-game/src/graph_host/motion_offboard/body_tweak.rs b/crates/skate-game/src/graph_host/motion_offboard/body_tweak.rs new file mode 100644 index 00000000..3a0a65b9 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/body_tweak.rs @@ -0,0 +1,179 @@ +//! TU3 OffboardBodyTweakBlend: ctor82BBAB98, Begin82BBACC0, +//! Update82BBACE0, End82BBB418, instance82BBB4D0. Original IDA only. +use super::MotionAnimation; +use crate::graph_host::motion_wipeout::Controls; +use skate_core::animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; + +const CHANNEL: &str = "AirBodyTweak"; // initializer82F87578 ->830BE028 + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Settings { + pub nb_cycle: String, + pub nb_into: String, + pub br_cycle: String, + pub br_into: String, +} +impl Settings { + pub fn parse(a: &Attributes<'_>) -> Self { + Self { + nb_cycle: a.text("NbCycAnim").unwrap_or("B_OBAIR_BODYTWEAK_NB_CYC").into(), + nb_into: a.text("NbIntoAnim").unwrap_or("B_OBAIR_BODYTWEAK_NB_INTO").into(), + br_cycle: a.text("BrCycAnim").unwrap_or("B_OBAIR_BODYTWEAK_BR_CYC").into(), + br_into: a.text("BrIntoAnim").unwrap_or("B_OBAIR_BODYTWEAK_BR_INTO").into(), + } + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Physical { + pub time_to_land: f32, + pub scalar_92: f32, + pub board_held: bool, +} + +#[derive(Default)] +pub(crate) struct State { + ticks: u32, + axes: [f32; 2], + into_started: bool, + cycle_started: bool, + released: bool, +} +impl State { + pub fn begin(&mut self) { + // Raw82BBACC0: zero8,20,21,22. Axes12/16 are only zeroed at allocation. + self.ticks = 0; + self.into_started = false; + self.cycle_started = false; + self.released = false; + } + + pub fn update( + &mut self, + settings: &Settings, + animation: &mut MotionAnimation, + controls: &mut Controls, + physical: Physical, + ) -> Result<(), String> { + let x = animation.motion_intent("OB_AirBodyTweakX"); + let y = animation.motion_intent("OB_AirBodyTweakY"); + let request = animation.motion_intent("OB_DoAirBodyTweak").is_some(); + let (directed, centered) = self.gates(x, y, request, physical.scalar_92); + let active = directed || centered; + //82BBB030 is blt: unordered does not take the near-landing branch. + if active && !(physical.time_to_land < 0.1) { + let remaining = animation.channels.remaining(CHANNEL); + if animation.channels.has(CHANNEL) || self.into_started { + if !(remaining > 0.01) && !self.cycle_started { + transition(animation, settings, physical.board_held, true)?; + self.cycle_started = true; + } + } else { + transition(animation, settings, physical.board_held, false)?; + self.into_started = true; + } + self.filter(x.unwrap_or(0.0), y.unwrap_or(0.0), centered); + for (name, value) in [b"bodytweakx", b"bodytweaky"].into_iter().zip(self.axes) { + animation.set_attribute(SettableAttribute { + name: encode(name), value, normalized: false, sequence_id: -1, + }); + } + //82BBB328 ->MGv200/8258FBF0;82BBB33C ->ISkaterAnimv116/82B97228. + controls.seed_from_air_tweak = true; + set_flag(animation, 0x8000, true)?; + } else if active && !(physical.time_to_land >= 0.1) { + //82BBB36C ->ISkaterAnimv112/82B97218: one-frame request bit16. + set_flag(animation, 0x10000, true)?; + } else if animation.channels.has(CHANNEL) { + let duration = if physical.time_to_land - 0.3 >= 0.0 { + 0.3 + } else { + physical.time_to_land + }; + animation.channels.end_with(CHANNEL, duration, true); + controls.seed_from_air_tweak = false; + set_flag(animation, 0x8000, false)?; + } + self.ticks = self.ticks.wrapping_add(1); + Ok(()) + } + + fn gates(&mut self, x: Option, y: Option, request: bool, scalar_92: f32) -> (bool, bool) { + if (self.ticks as i32) > 80 { + self.released = true; + } + //82BBAED8..EE8 uses signed comparisons, not abs(axis). + if !self.released && x.is_some_and(|x| !(x >= 0.1)) + && y.is_some_and(|y| !(y >= 0.1)) { + self.released = true; + } + let x = x.unwrap_or(0.0); + let y = y.unwrap_or(0.0); + let squared = y.mul_add(y, x * x); + //Same independent PC seed as native_arithmetic; both native refinements + //remain below. This is not a claim of bit-exact Xenon rsqrt emulation. + let mut inverse = squared.sqrt().recip(); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let magnitude = if squared == 0.0 { 0.0 } else { squared * inverse }; + let directed = self.released && magnitude >= 0.1; + //82BBAFD4 is ble. This preserves its unordered path as well. + (directed, request || (!(scalar_92 <= 4.0) && !directed)) + } + + fn filter(&mut self, x: f32, y: f32, centered: bool) { + //Raw82BBB290..2C8: preserve fmuls/fmadds order and axis identity. + self.axes = if centered { + self.axes.map(|value| value * 0.85) + } else { + [x.mul_add(0.15, self.axes[0] * 0.85), self.axes[1].mul_add(0.85, y * 0.15)] + }; + } + + pub fn end(&mut self, animation: &mut MotionAnimation) { + //82BBB418 ends the channel from its last frame, but does not clear MGbit20. + if animation.channels.has(CHANNEL) { + animation.channels.end_with(CHANNEL, 0.1, true); + } + } +} + +fn set_flag(animation: &mut MotionAnimation, bit: u32, value: bool) -> Result<(), String> { + let flags = animation.skater_animation_flags.as_mut() + .ok_or("OffboardBodyTweakBlend requires SkaterAnim flag owner")?; + *flags = (*flags & !bit) | if value { bit } else { 0 }; + Ok(()) +} + +fn transition(animation: &mut MotionAnimation, settings: &Settings, held: bool, cycle: bool) -> Result<(), String> { + let name = match (held, cycle) { + (false, false) => &settings.nb_into, + (false, true) => &settings.nb_cycle, + (true, false) => &settings.br_into, + (true, true) => &settings.br_cycle, + }; + //82BBB164/270 call IChannelAnimatablev16=82D1D090. Stack57=false + //resurrection,5F=true create-if-missing,67=true use animation attributes. + animation.transition_channel(CHANNEL, name, ChannelSettings { + priority: 0, keep_alive: cycle, mirrored: false, speed: 1.0, + blend_in: if cycle { 0.5 } else { 0.15 }, hold_during_blend_in: false, + blend_out: if cycle { 0.3 } else { 0.0 }, hold_during_blend_out: false, + use_attributes: true, + }, TransitionSettings { + kind: 1, seconds: if cycle { 0.1 } else { 0.15 }, under: 0, + matching: 0, use_channels_from_weights: false, + }, false, true)?; + Ok(()) +} + +#[cfg(test)] +#[path = "body_tweak/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_offboard/body_tweak/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/body_tweak/tests.rs new file mode 100644 index 00000000..2ee24c0e --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/body_tweak/tests.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn native_air_tweak_release_latch_keeps_signed_axes_and_strict_counter() { + let mut state = State::default(); + assert_eq!(state.gates(Some(1.0), Some(0.0), false, 0.0), (false, false)); + state.ticks = 80; + assert!(!state.gates(Some(1.0), Some(0.0), false, 0.0).0); + state.ticks = 81; + assert!(state.gates(Some(1.0), Some(0.0), false, 0.0).0); + state.begin(); + assert!(state.gates(Some(-1.0), Some(0.0), false, 0.0).0); + state.begin(); + assert!(!state.gates(None, Some(0.0), false, 0.0).0); + assert!(!state.released, "both native intent lookups must succeed to release"); + assert_eq!(state.gates(Some(0.0), Some(0.0), false, 4.0), (false, false)); + assert_eq!(state.gates(Some(0.0), Some(0.0), false, 4.01), (false, true)); + assert_eq!(state.gates(Some(1.0), Some(0.0), true, 0.0), (true, true)); +} + +#[test] +fn native_air_tweak_begin_preserves_filters_and_center_request_decays_them() { + let mut state = State::default(); + state.filter(1.0, -1.0, false); + assert_eq!(state.axes, [0.15, -0.15]); + state.begin(); + assert_eq!(state.axes, [0.15, -0.15]); + state.filter(-1.0, 1.0, true); + assert_eq!(state.axes, [0.15 * 0.85, -0.15 * 0.85]); + assert_eq!(state.ticks, 0); + assert!(!state.released && !state.into_started && !state.cycle_started); +} + +#[test] +#[ignore = "requires private stock OffBoard bank; graph leaf/pose regression, not gameplay acceptance"] +fn stock_air_tweak_channels_parameters_pose_requests_and_teardown() { + use skate_core::animation::playback_tree::{Evaluation, PoseCommand}; + use skate_core::animation::playback::{PlaybackRequest, PlaybackService}; + use skate_data::animation_banks::AnimationBanks; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT") + .expect("Set SKATE3_ASSET_ROOT")); + let banks = AnimationBanks::load(&root).unwrap(); + let evaluator = crate::animation_pose::PoseEvaluator::from_banks(&banks).unwrap(); + let settings = Settings { + nb_cycle: "B_OBAIR_BODYTWEAK_NB_CYC".into(), + nb_into: "B_OBAIR_BODYTWEAK_NB_INTO".into(), + br_cycle: "B_OBAIR_BODYTWEAK_BR_CYC".into(), + br_into: "B_OBAIR_BODYTWEAK_BR_INTO".into(), + }; + for held in [false, true] { + let mut animation = MotionAnimation::from_metadata(banks.metadata().unwrap()); + animation.skater_animation_flags = Some(0); + animation.play(PlaybackRequest { + animation: if held { "BR_STAND_0_CYC" } else { "NB_STAND_0_CYC" }.into(), + speed: 1.0, start_time: 0.0, + transition: TransitionSettings { + kind: 1, seconds: 0.0, under: 0, matching: 0, + use_channels_from_weights: false, + }, + }).unwrap(); + let mut controls = Controls::default(); + let mut state = State::default(); + let mut physical = Physical { time_to_land: 2.0, scalar_92: 0.0, board_held: held }; + let mut poses = Vec::new(); + state.begin(); + animation.motion_intents.insert("OB_AirBodyTweakX", 0.0); + animation.motion_intents.insert("OB_AirBodyTweakY", 0.0); + state.update(&settings, &mut animation, &mut controls, physical).unwrap(); + assert!(!animation.channels.has(CHANNEL)); + for direction in [[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0]] { + animation.motion_intents.insert("OB_AirBodyTweakX", direction[0]); + animation.motion_intents.insert("OB_AirBodyTweakY", direction[1]); + for _ in 0..90 { + state.update(&settings, &mut animation, &mut controls, physical).unwrap(); + assert!(animation.pending_parameter(encode(b"bodytweakx")).is_some()); + assert!(animation.pending_parameter(encode(b"bodytweaky")).is_some()); + assert_ne!(animation.skater_animation_flags.unwrap() & 0x8000, 0); + assert!(controls.seed_from_air_tweak); + animation.apply_parameters().unwrap(); + animation.advance(1.0 / 60.0, 0.0); + animation.refresh_tree_attributes().unwrap(); + let commands = animation.evaluate_pose(Evaluation { + cull_threshold: 0.01, update_history: true, + }).unwrap(); + let pose = evaluator.evaluate(&commands).unwrap(); + assert!(pose.iter().all(|s| s.rotation.iter().chain(s.translation.iter()) + .all(|v| v.is_finite()))); + if commands.iter().any(|c| matches!(c, PoseCommand::ChannelBlend { .. })) { + poses.push(pose); + } + } + } + assert!(state.into_started && state.cycle_started); + assert!(poses.windows(2).any(|p| p[0].iter().zip(&p[1]) + .any(|(a, b)| a.rotation != b.rotation || a.translation != b.translation))); + physical.time_to_land = 0.05; + animation.skater_animation_flags = Some(0); + state.update(&settings, &mut animation, &mut controls, physical).unwrap(); + assert_ne!(animation.skater_animation_flags.unwrap() & 0x10000, 0); + state.end(&mut animation); + for _ in 0..10 { animation.advance(1.0 / 60.0, 0.0); } + assert!(!animation.channels.has(CHANNEL)); + assert!(controls.seed_from_air_tweak, "native End preserves Wipeout handoff latch"); + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/dismount.rs b/crates/skate-game/src/graph_host/motion_offboard/dismount.rs new file mode 100644 index 00000000..2f9b0335 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/dismount.rs @@ -0,0 +1,21 @@ +//! DisableDismount condition82BA5368 in the original Skate3 TU3 executable. +use skate_core::graph::conditions::PushBrakeInputs; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Condition; + +impl Condition { + pub fn parse(attributes: &Attributes<'_>) -> Option { + (attributes.text("name") == Some("DisableDismount")).then_some(Self) + } + + ///Uses the same published Ground80.Y and Skeleton598 as DisablePushBrake. + ///The native leaf requires the physical component; absence is an error. + pub fn evaluate(self, physical: Option<&PushBrakeInputs>) -> Result { + let physical = physical.ok_or("DisableDismount requires actual Ground80 and Skeleton598")?; + //822F8C9C; strict SIMD comparison is false for an unordered normal. + Ok(f32::from_bits(0x3F23_D70A) > physical.ground_axis_y + || physical.skeleton_disables_push_brake) + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/match_air_time.rs b/crates/skate-game/src/graph_host/motion_offboard/match_air_time.rs new file mode 100644 index 00000000..1f408dbc --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/match_air_time.rs @@ -0,0 +1,74 @@ +//! Native MatchAirTime: factory82BCA308, Begin82BB9F48, +//! Update82BBA070, instance82BBA390. End is the empty82B61BB8. +use super::MotionAnimation; +use skate_core::animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; + +#[derive(Default)] +pub(crate) struct State { + cadence_start: f32, + duration: f32, + translation: [f32; 4], + first_update: bool, +} +impl State { + pub fn begin(&mut self, cadence: f32, duration: f32, translation: [f32; 4]) { + self.first_update = true; + self.cadence_start = cadence; + self.duration = duration; + self.translation = translation; + } + + pub fn update(&mut self, animation: &mut MotionAnimation, + remaining: f32, duration: f32, translation: [f32; 4]) { + if let Some(fraction) = self.seek_fraction(remaining, duration) { + animation.synchronize_air_time(fraction); + } + self.translation = bounded_translation(translation); + self.duration = duration; + for (name, value) in [ + ("CadenceStartPercent", self.cadence_start), + ("AnimTime", self.duration), + ("AnimTransX", self.translation[0]), + ("AnimTransY", self.translation[1]), + ("AnimTransZ", self.translation[2]), + ] { + //82BBA2C0..378: r6=0, r7=-1 for each v80 SetAttribute. + animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), value, normalized: false, sequence_id: -1, + }); + } + self.first_update = false; + } + + fn seek_fraction(&self, remaining: f32, duration: f32) -> Option { + //82BBA16C skips the first update;184 is ble (unordered falls through). + if self.first_update || duration <= 0.0 { return None; } + let progress = 1.0 - remaining / duration; + let lower = if -progress >= 0.0 { 0.0 } else { progress }; + Some(if 1.0 - lower >= 0.0 { lower } else { 1.0 }) + } +} + +fn bounded_translation(vector: [f32; 4]) -> [f32; 4] { + //82BBA218 vmsum3, two rsqrt refinements, zero selection. Independent + //PC seed, not a claim of bit-exact Xenon reciprocal-square-root emulation. + let squared = (vector[0] * vector[0] + vector[1] * vector[1]) + vector[2] * vector[2]; + let mut inverse = squared.sqrt().recip(); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + if length > 0.01 { + let bounded = if length - 2.0 >= 0.0 { 2.0 } else { length }; + //82BBA2A0 scales all four lanes. Published scalar parameters are XYZ. + vector.map(|v| v * (bounded / length)) + } else { vector } +} + +#[cfg(test)] +#[path = "match_air_time/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_offboard/match_air_time/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/match_air_time/tests.rs new file mode 100644 index 00000000..aa3b660d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/match_air_time/tests.rs @@ -0,0 +1,135 @@ +use super::*; + +#[test] +fn match_air_time_first_update_and_reentry_skip_seek_then_use_remaining_over_total() { + let mut state = State::default(); + state.begin(0.4, 0.8, [0.0; 4]); + assert_eq!(state.seek_fraction(0.4, 0.8), None); + state.first_update = false; + assert_eq!(state.seek_fraction(0.4, 0.8), Some(0.5)); + assert_eq!(state.seek_fraction(1.0, 0.8), Some(0.0)); + assert_eq!(state.seek_fraction(-0.1, 0.8), Some(1.0)); + assert_eq!(state.seek_fraction(0.0, 0.0), None); + state.begin(0.8, 1.0, [1.0; 4]); + assert_eq!(state.seek_fraction(0.4, 1.0), None); +} + +#[test] +fn match_air_time_bounds_displacement_by_xyz_length_and_scales_all_lanes() { + assert_eq!(bounded_translation([0.0; 4]), [0.0; 4]); + assert_eq!(bounded_translation([0.001, 0.0, 0.0, 7.0]), [0.001, 0.0, 0.0, 7.0]); + let bounded = bounded_translation([0.0, 3.0, 4.0, 10.0]); + for (actual, expected) in bounded.into_iter().zip([0.0, 1.2, 1.6, 4.0]) { + assert!((actual - expected).abs() < 0.00001); + } +} + +#[test] +fn match_air_time_publishes_five_selection_parameters_from_correct_owners() { + let metadata = skate_data::animation_metadata::AnimationMetadata::parse(r#"{ + "version":1,"source_bank":"synthetic-test", + "source_sha256":"0000000000000000000000000000000000000000000000000000000000000000", + "source_bytes":48,"clips":[],"unsupported_trees":[] + }"#).unwrap(); + let mut animation = MotionAnimation::from_metadata(metadata); + let mut state = State::default(); + state.begin(0.3, 0.7, [1.0; 4]); + state.update(&mut animation, 0.6, 0.9, [0.0, 0.5, 1.0, 0.0]); + for (name, expected) in [ + ("CadenceStartPercent", 0.3), ("AnimTime", 0.9), + ("AnimTransX", 0.0), ("AnimTransY", 0.5), ("AnimTransZ", 1.0), + ] { + assert!((animation.pending_parameter(encode(name.as_bytes())).unwrap() - expected).abs() < 0.00001); + } + assert!(!state.first_update); +} + +#[test] +#[ignore = "requires private stock banks; selection/timeline/landing pose bridge, not physical acceptance"] +fn stock_match_air_time_selects_jump_advances_pose_and_preserves_landing_identity() { + use skate_core::animation::{ + playback::{PlaybackRequest, PlaybackService, TransitionSettings}, + playback_tree::{Evaluation, PoseCommand}, + }; + use skate_data::animation_metadata::TreeMetadata; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT") + .expect("Set SKATE3_ASSET_ROOT")); + let banks = skate_data::animation_banks::AnimationBanks::load(&root).unwrap(); + let evaluator = crate::animation_pose::PoseEvaluator::from_banks(&banks).unwrap(); + for held in [false, true] { + for running in [false, true] { + let family = if held { "BR" } else { "NB" }; + let selection = format!("SS_{family}_JUMP_{}", if running { "RUN_INTO" } else { "STAND_0_INTO" }); + let metadata = banks.metadata().unwrap(); + let TreeMetadata::SelectionSpace(space) = metadata.tree(&selection).unwrap() + else { panic!("Expected stock jump selection space"); }; + let indices = ["CadenceStartPercent", "AnimTime", "AnimTransX", "AnimTransY", "AnimTransZ"] + .map(|name| space.parameters.iter().position(|p| p.name.eq_ignore_ascii_case(name)).unwrap()); + let candidate = space.candidates.iter().find(|c| { + c.child.starts_with(&format!("J{family}_")) + && f32::from_bits(c.value_bits[indices[1]]) > 0.0 + && indices[2..].iter().map(|&i| f32::from_bits(c.value_bits[i]).powi(2)) + .sum::() < 4.0 + }).expect("Stock candidate inside native displacement cap"); + let selected = candidate.child.clone(); + let values = indices.map(|i| f32::from_bits(candidate.value_bits[i])); + let translation = [values[2], values[3], values[4], 0.0]; + let suffix = selected.split_once("_TO_").unwrap().1; + let landing = format!("{family}_LAND_{suffix}_INTO_{}", + if running { "RUN_FWD" } else { "STAND" }); + metadata.tree(&landing).expect("Stock land.xml counterpart exists"); + let mut animation = MotionAnimation::from_metadata(metadata); + //Explicit initialized owner, as in the stock body-tweak fixture. + animation.skater_animation_flags = Some(0); + let transition = |seconds| TransitionSettings { + kind: 1, seconds, under: 0, matching: 0, use_channels_from_weights: false, + }; + animation.play(PlaybackRequest { + animation: selection, speed: 1.0, start_time: 0.0, transition: transition(0.1), + }).unwrap(); + let mut state = State::default(); + state.begin(values[0], values[1], translation); + state.update(&mut animation, values[1], values[1], translation); + animation.apply_parameters().unwrap(); + animation.advance(1.0 / 60.0, 0.0); + animation.refresh_tree_attributes().unwrap(); + assert!(animation.tree_attributes().iter().any(|a| a.name == encode(selected.as_bytes())), + "Selected clip lost landing identity: {selected}"); + let first = animation.evaluate_pose(Evaluation { + cull_threshold: 0.01, update_history: true, + }).unwrap(); + let first_pose = evaluator.evaluate(&first).unwrap(); + let length = animation.current_length().unwrap(); + state.update(&mut animation, values[1] * 0.5, values[1], translation); + animation.apply_parameters().unwrap(); + animation.advance(1.0 / 60.0, 0.0); + let midway = animation.evaluate_pose(Evaluation { + cull_threshold: 0.01, update_history: true, + }).unwrap(); + let expected_time = (length * 0.5 + 1.0 / 60.0).min(length); + assert!(midway.iter().any(|c| matches!(c, PoseCommand::Clip { name, time, .. } + if name == &selected && (*time - expected_time).abs() < 0.0001))); + let midway_pose = evaluator.evaluate(&midway).unwrap(); + assert!(first_pose.iter().zip(&midway_pose) + .any(|(a,b)| a.rotation != b.rotation || a.translation != b.translation)); + //Named stock landing tree and .05 blend; direct selection is NOT + //a substitute for the parent's full graph/physics regression. + animation.play(PlaybackRequest { + animation: landing.clone(), speed: 1.0, start_time: 0.0, + transition: transition(0.05), + }).unwrap(); + for _ in 0..4 { + animation.apply_parameters().unwrap(); + animation.advance(1.0 / 60.0, 0.0); + animation.refresh_tree_attributes().unwrap(); + let commands = animation.evaluate_pose(Evaluation { + cull_threshold: 0.01, update_history: true, + }).unwrap(); + assert!(commands.iter().any(|c| matches!(c, PoseCommand::Clip { name, .. } + if name == &landing))); + assert!(evaluator.evaluate(&commands).unwrap().iter().all(|s| + s.rotation.iter().chain(s.translation.iter()).all(|v| v.is_finite()))); + } + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/runout.rs b/crates/skate-game/src/graph_host/motion_offboard/runout.rs new file mode 100644 index 00000000..a801a913 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/runout.rs @@ -0,0 +1,98 @@ +//! AddRunoutAttribs: Begin82BBA4A8, Update82BBA770, state82BBA830. +//! End is82B61BB8. Values are sampled once per activation, never on Update. +use skate_core::{ + animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + player::wipeout_state::{math, orientation}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Operation; +impl Operation { + pub fn parse(attributes: &Attributes<'_>) -> Option { + (attributes.text("name") == Some("AddRunoutAttribs")).then_some(Self) + } +} + +///Borrowed values from the actual published physical bundle and animation owner. +#[derive(Clone, Copy, Debug)] +pub struct Observation { + pub offboard_flag_331: bool, + pub offboard_velocity_128: [f32; 4], + pub reckoning_velocity_16: [f32; 4], + pub reckoning_up_96: [f32; 4], + ///Exact first vector of physical bundle20, loaded at82BBA620. + pub skeleton_vector_0: [f32; 4], + ///Live ISkaterAnim virtual28 result at Begin, not a retained pose mirror. + pub animation_mirrored: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct State { + pub angle_degrees_8: f32, + ///Native constructor does not initialize12. None records that fact without + ///inventing a zero speed if a Begin observation was unavailable. + pub speed_12: Option, +} +impl Default for State { + fn default() -> Self { + Self { + angle_degrees_8: 0.0, + speed_12: None, + } + } +} +impl State { + ///None is permitted only for an actually absent native physical/animation + ///component. An unavailable implementation must remain an upstream error. + pub fn begin(&mut self, observation: Option) { + let Some(observation) = observation else { + return; + }; + let velocity = if observation.offboard_flag_331 { + observation.offboard_velocity_128 + } else { + observation.reckoning_velocity_16 + }; + let mut angle = orientation::projected_angle( + observation.skeleton_vector_0, + velocity, + observation.reckoning_up_96, + ); + if observation.animation_mirrored { + angle = -angle; + } + self.speed_12 = Some(math::length(velocity)); + self.angle_degrees_8 = math::wrap_angle(angle) * f32::from_bits(0x4265_2EE1); + } + + ///Write to the canonical playback queue in the original order. This does + ///not modify the captured state. A genuinely absent animation skips Update. + pub fn update(&self, animation: Option<&mut impl AttributeSink>) -> Result<(), &'static str> { + let Some(animation) = animation else { + return Ok(()); + }; + let speed = self + .speed_12 + .ok_or("AddRunoutAttribs speed12 has no successful Begin observation")?; + for (name, value) in [ + ("BipedStartAngle", self.angle_degrees_8), + ("BipedSpeed", speed), + ] { + animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), + value, + normalized: false, + sequence_id: -1, + }); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "runout/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_offboard/runout/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/runout/tests.rs new file mode 100644 index 00000000..5053d16f --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/runout/tests.rs @@ -0,0 +1,64 @@ +use super::*; +use skate_core::animation::playback_parameters::SettableAttributes; +fn observation() -> Observation { + Observation { + offboard_flag_331: true, + offboard_velocity_128: [3.0, 0.0, 4.0, 91.0], + reckoning_velocity_16: [0.0, 0.0, 2.0, 0.0], + reckoning_up_96: [0.0, 1.0, 0.0, 0.0], + skeleton_vector_0: [0.0, 0.0, 1.0, 0.0], + animation_mirrored: false, + } +} +#[test] +fn offboard_selection_and_mirror_are_sampled_at_begin() { + let mut state = State::default(); + state.begin(Some(observation())); + assert!((state.speed_12.unwrap() - 5.0).abs() < 0.0001); + let angle = state.angle_degrees_8; + assert!(angle > 36.0 && angle < 38.0); + let mut mirrored = observation(); + mirrored.animation_mirrored = true; + state.begin(Some(mirrored)); + assert!((state.angle_degrees_8 + angle).abs() < 0.0001); + mirrored.offboard_flag_331 = false; + state.begin(Some(mirrored)); + assert!((state.speed_12.unwrap() - 2.0).abs() < 0.0001); + assert!(state.angle_degrees_8.abs() < 0.01); +} +#[test] +fn update_reuses_capture_and_preserves_queue_order_and_flags() { + let mut state = State::default(); + state.begin(Some(observation())); + let captured = state; + let mut sink = SettableAttributes::default(); + state.update(Some(&mut sink)).unwrap(); + state.begin(None); + state.update(Some(&mut sink)).unwrap(); + assert_eq!(state, captured); + let entries = sink.entries(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, encode(b"BipedStartAngle")); + assert_eq!(entries[1].name, encode(b"BipedSpeed")); + assert_eq!(entries[0].value, captured.angle_degrees_8); + assert_eq!(entries[1].value, captured.speed_12.unwrap()); + assert!( + entries + .iter() + .all(|entry| !entry.normalized && entry.sequence_id == -1) + ); +} +#[test] +fn absent_begin_does_not_fabricate_speed_and_stationary_begin_is_valid() { + let mut state = State::default(); + let mut sink = SettableAttributes::default(); + assert!(state.update(Some(&mut sink)).is_err()); + assert!(sink.entries().is_empty()); + state.update(None::<&mut SettableAttributes>).unwrap(); + let mut input = observation(); + input.offboard_velocity_128 = [0.0; 4]; + state.begin(Some(input)); + assert_eq!(state.angle_degrees_8, 0.0); + assert_eq!(state.speed_12, Some(0.0)); + state.update(Some(&mut sink)).unwrap(); +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/toggle_board.rs b/crates/skate-game/src/graph_host/motion_offboard/toggle_board.rs new file mode 100644 index 00000000..fe6453aa --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/toggle_board.rs @@ -0,0 +1,143 @@ +//! Original ToggleBoard lifecycle adapter, TU3 82BA8FD8/8FE8 and 82B61BB8. +use super::motion::MotionAnimation; +use skate_core::{ + animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + player::offboard::toggle_board::{Channel, Clip, Command, Input, State}, +}; +use crate::graph_host::outputs::BoardControls; + +/// Completed native output, supplied by the board-possession/physical owner. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub grabbing_object: bool, + pub holding_board: bool, + /// Completed OffBoard312: free/hidden/returning, not merely !held311. + pub free_board: bool, + pub retrieval_blocked: bool, + pub retrieval_active: bool, + pub yaw_radians: f32, + pub pitch_radians: f32, +} +const CHANNEL: &str = "RetrieveBoard"; + +pub fn execute( + state: &mut State, + animation: &mut MotionAnimation, + actions: BoardControls, + physical: Option, + mirrored: Option, + phase: u8, +) -> Result<(), String> { + if phase == 0 { + state.begin(); + return Ok(()); + } + // End82B61BB8 does not stop the persistent channel. + if phase != 1 { + return Ok(()); + } + let physical = physical.ok_or("ToggleBoard requires completed board-possession output (OffBoard36/40/304/311/313 and bundle28 byte87)")?; + let input = Input { + grabbing_object: physical.grabbing_object, + holding_board: physical.holding_board, + retrieval_blocked: physical.retrieval_blocked, + retrieval_active: physical.retrieval_active, + yaw_radians: physical.yaw_radians, + pitch_radians: physical.pitch_radians, + mirrored: mirrored.ok_or("ToggleBoard requires the actual animation mirror state")?, + drop_requested: actions.drop_requested, + throw_requested: actions.throw_requested, + retrieve_requested: actions.retrieve_requested, + }; + let output = state.update( + input, + Channel { + exists: animation.channels.has(CHANNEL), + remaining: animation.channels.remaining(CHANNEL), + elapsed: animation.channels.elapsed(CHANNEL), + }, + ); + // Original appends MG physical attributes before channel operations, + // pulse after sequencing, and animation-only yaw/pitch last. + if output.retrieving { + publish(animation, "OB_RetrievingBoard"); + } + if output.dropping { + publish(animation, "OB_DroppingBoard"); + } + match output.channel { + Some(Command::Stop { blend_seconds }) => { + animation.channels.end_with(CHANNEL, blend_seconds, false) + } + Some(command) => { + let (clip, sequence) = match command { + Command::BlendTo(clip) => (clip, false), + Command::SequenceTo(clip) => (clip, true), + Command::Stop { .. } => unreachable!(), + }; + let is_out = matches!(clip, Clip::FrontOut | Clip::BackOut); + let hold = sequence || is_out; + let settings = ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: 0.25, + blend_out: 0.25, + hold_during_blend_in: hold, + hold_during_blend_out: hold, + use_attributes: true, + }; + // Vtable8231E128: +16 = 82D1D090 (blend), +20 = 82D1D030 + // (sequence). Both preserve the channel owner via82D1D0F0. + animation.transition_channel( + CHANNEL, + clip.name(), + settings, + TransitionSettings { + kind: if sequence { 4 } else { 2 }, + seconds: if sequence { 0.0 } else { 0.25 }, + under: 0, + matching: 0, + use_channels_from_weights: false, + }, + true, + !sequence && !is_out, + )?; + } + None => {} + } + if output.retrieve { + publish(animation, "OB_RetrieveBoard"); + } + if let Some([yaw, pitch]) = output.yaw_pitch { + attribute(animation, "yaw", yaw); + attribute(animation, "pitch", pitch); + } + Ok(()) +} +fn publish(animation: &mut MotionAnimation, name: &str) { + //82BA9434/9628: actor1800 interface slot16 ->8258F6E0 appends + //a24-byte MG attribute; it does NOT insert a motion intention. Actor + //82593760/70 copies that list into the actual physical input packet. + animation.emit_packet(encode(name.as_bytes()), 1.0); + //The second call (82BA9464/9658) separately sets the animation parameter. + attribute(animation, name, 1.0); +} +fn attribute(animation: &mut MotionAnimation, name: &str, value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), + value, + normalized: false, + sequence_id: -1, + }); +} + +#[cfg(test)] +#[path = "toggle_board/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_offboard/toggle_board/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/toggle_board/tests.rs new file mode 100644 index 00000000..4808c1eb --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/toggle_board/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use skate_core::animation::{playback_clip::PlaybackClip, playback_tree::PlaybackTree}; +use skate_core::player::offboard::toggle_board::Phase; + +fn animation() -> MotionAnimation { + MotionAnimation::from_metadata(skate_data::animation_metadata::AnimationMetadata::parse( + r#"{"version":1,"source_bank":"test-only","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"#, + ).unwrap()) +} +fn physical() -> Physical { + Physical { + grabbing_object: false, + holding_board: true, + free_board: false, + retrieval_blocked: false, + retrieval_active: false, + yaw_radians: 0.0, + pitch_radians: 0.0, + } +} +#[test] +fn dropping_terminal_tick_publishes_physical_attribute_and_animation_parameter() { + let mut state = State::default(); + state.phase = Phase::DropPlaying; + let mut animation = animation(); + execute( + &mut state, + &mut animation, + BoardControls::default(), + Some(physical()), + Some(false), + 1, + ) + .unwrap(); + assert_eq!(animation.motion_attributes.len(), 1); + assert_eq!(animation.motion_attributes[0].name, encode(b"OB_DroppingBoard")); + assert_eq!(animation.motion_attributes[0].value, 1.0); + assert!(!animation.motion_intents.contains_key("OB_DroppingBoard")); + assert_eq!(animation.pending_parameter(encode(b"OB_DroppingBoard")), Some(1.0)); + assert_eq!(state.phase, Phase::Idle); +} + +#[test] +fn all_toggle_publications_reach_physics_attributes_and_animation_parameters() { + let mut animation = animation(); + for name in ["OB_RetrievingBoard", "OB_DroppingBoard", "OB_RetrieveBoard"] { + publish(&mut animation, name); + let packet = animation.motion_attributes.last().unwrap(); + assert_eq!(packet.name, encode(name.as_bytes())); + assert_eq!(packet.value, 1.0); + assert_eq!(animation.pending_parameter(encode(name.as_bytes())), Some(1.0)); + assert!(!animation.motion_intents.contains_key(name)); + } + //Yaw/pitch parameterize the real retrieval tree; native does not append + //these to the physical MG attribute list. + attribute(&mut animation, "yaw", 45.0); + attribute(&mut animation, "pitch", -5.0); + assert_eq!(animation.motion_attributes.len(), 3); + assert_eq!(animation.pending_parameter(encode(b"yaw")), Some(45.0)); + assert_eq!(animation.pending_parameter(encode(b"pitch")), Some(-5.0)); + animation.begin_graph_update(); + assert!(animation.motion_attributes.is_empty(), "Recall pulse leaked to next frame"); +} +#[test] +fn lifecycle_end_leaves_persistent_channel_and_begin_resets_state() { + let mut animation = animation(); + animation.channels.insert( + CHANNEL.into(), + PlaybackTree::Clip { + name: "test".into(), + clip: PlaybackClip::new(301.0, 30.0, 1.0, 0, Vec::new()), + }, + ChannelSettings { + priority: 0, + keep_alive: true, + mirrored: false, + speed: 1.0, + blend_in: 0.0, + blend_out: 0.0, + hold_during_blend_in: false, + hold_during_blend_out: false, + use_attributes: false, + }, + ); + animation.channels.advance(0.3, 0.0); + assert!((animation.channels.elapsed(CHANNEL) - 0.3).abs() < 0.00001); + assert_eq!(animation.channels.elapsed("missing"), 0.0); + let mut state = State::default(); + state.phase = Phase::DropPlaying; + execute(&mut state, &mut animation, BoardControls::default(), None, None, 2).unwrap(); + assert_eq!(state.phase, Phase::DropPlaying); + execute(&mut state, &mut animation, BoardControls::default(), None, None, 0).unwrap(); + assert_eq!(state.phase, Phase::Idle); + animation.channels.advance(1.0, 0.0); + animation.channels.retire(); + assert!(animation.channels.has(CHANNEL)); +} diff --git a/crates/skate-game/src/graph_host/motion_riding.rs b/crates/skate-game/src/graph_host/motion_riding.rs index 54a70871..68e6cd41 100644 --- a/crates/skate-game/src/graph_host/motion_riding.rs +++ b/crates/skate-game/src/graph_host/motion_riding.rs @@ -105,8 +105,8 @@ impl RidingOperation { "UpdateTimeSinceKickturn" => Self::TimeSinceKickturn, "UpdateManualOutTimer" => Self::ManualOutTimer, "SetManualOutTimer" => Self::SetManualOutTimer(f32::from_bits( - a.float_bits("length", 0x3e29fbe7))), //82BCA058 constructor default - + a.float_bits("length", 0x3e29_fbe7), //factory82BCA0FC + )), _ => return None, }) } @@ -218,10 +218,6 @@ impl RidingState { self.time_since_kickturn += frame.dt; } } //82BAE848/8258F940 - RidingOperation::SetManualOutTimer(length) => { - //82BB9158 is the End callback (vtable8232090C+56). - if phase == 2 { self.manual_out_timer = length; } - } RidingOperation::ManualOutTimer => { if phase == 2 { self.manual_out_timer = 0.0; @@ -232,6 +228,12 @@ impl RidingState { self.manual_out_timer = if remaining >= 0.0 { remaining } else { 0.0 }; } //82BB91A8/8258F998 } + RidingOperation::SetManualOutTimer(length) => { + //Vtable8232090C: Begin/Update empty; End82BB9158 sets length. + if phase == 2 { + self.manual_out_timer = length; + } + } } Ok(()) } diff --git a/crates/skate-game/src/graph_host/motion_score_execute.rs b/crates/skate-game/src/graph_host/motion_score_execute.rs new file mode 100644 index 00000000..3ba7eab7 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_score_execute.rs @@ -0,0 +1,24 @@ +//! Stateless score and bump lifecycles using the same MotionHost publication. +use super::*; +impl MotionHost { + pub(super) fn execute_score_or_bump(&mut self, operation: &MotionOperation, phase: u8) -> Result { + match operation { + MotionOperation::ScoringTrick(operation) => { + operation.execute(&mut self.score_packet.trick_names, &mut self.score_packet.flags, phase); + } + MotionOperation::SetBumpCoefficients { x, y } => { + if phase == 0 { + let values = self.bump_settings.coefficients( + self.bump_acceleration.ok_or("SetBumpCoefficients requires completed acceleration")?, + self.animation.skater_animation_flags.ok_or("SetBumpCoefficients requires actual stance")? & 0x40000000 != 0, + ); + for (name, value) in [*x, *y].into_iter().zip(values) { + self.animation.set_attribute(SettableAttribute { name, value, normalized: false, sequence_id: -1 }); + } + } + } + _ => return Ok(false), + } + Ok(true) + } +} \ No newline at end of file diff --git a/crates/skate-game/src/graph_host/motion_scoring_trick.rs b/crates/skate-game/src/graph_host/motion_scoring_trick.rs new file mode 100644 index 00000000..25394751 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_scoring_trick.rs @@ -0,0 +1,38 @@ +//! ScoringTrick lifecycle from original Skate 3 TU3 82BCC030/82BBFA88. +//! Update publishes the same authored encoded name to both score-name slots; +//! Begin and End are empty. No animation, force, or trick-selection side effect. +use skate_core::animation::{output::attributes::AttributeName, skeleton_input::name::encode}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub struct Operation { + pub trick: AttributeName, +} + +impl Operation { + pub fn parse(attributes: &Attributes<'_>) -> Result { + let trick = attributes.text("trick").ok_or("ScoringTrick requires authored trick")?; + Ok(Self { trick: encode(trick.as_bytes()) }) + } + + /// Vtable823214D0: Update at+52, while Begin+48 and End+56 are blr. + pub fn execute(&self, names: &mut Names, flags: &mut u32, phase: u8) { + if phase != 1 { + return; + } + //8258FA20: oris flags,0x100; copies the two input names verbatim. + //82BBFA88 supplies two copies of the same name: no mirror selection. + *flags |= 0x0100_0000; + names.first = Some(self.trick); + names.second = Some(self.trick); + } +} + +/// MotionGraph full-object5924 and5948, retained alongside score flags5972. +/// Native24-byte slots contain five encoded words plus initialized padding; +/// host representation preserves the names without copying native padding. +#[derive(Default, Debug)] +pub struct Names { + pub first: Option, + pub second: Option, +} diff --git a/crates/skate-game/src/graph_host/motion_stock_conditions.rs b/crates/skate-game/src/graph_host/motion_stock_conditions.rs new file mode 100644 index 00000000..390aed2b --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_stock_conditions.rs @@ -0,0 +1,79 @@ +//! Recovered stock predicates using completed physical publications. +//! Unsupported archive guesses are deliberately left to the graph's diagnostic path. +use super::motion::MotionHost; +use skate_core::graph::conditions::NumericCondition; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum Condition { + CanLandOnBoard, + DistToEdge(NumericCondition), + IsDeckFree, + IsBipedCommittedToMotion, + EnoughDistToObstacle { database: String, animation: String }, + CanBipedLand, + IsCrouchedEnoughForBlendToGrabCycle, + TrucksOrDeckInContact, + PhysicsWantsManualExit, + ApexReached, +} +impl Condition { + pub fn recognizes(name: &str) -> bool { + matches!(name, "CanLandOnBoard" | "DistToEdge" | "IsDeckFree" | + "IsBipedCommittedToMotion" | "EnoughDistToObstacle" | "CanBipedLand" | + "IsCrouchedEnoughForBlendToGrabCycle" | "TrucksOrDeckInContact" | + "PhysicsWantsManualExit" | "ApexReached") + } + pub fn parse(a: &Attributes<'_>) -> Self { + match a.text("name").unwrap_or("") { + "CanLandOnBoard" => Self::CanLandOnBoard, + "DistToEdge" => Self::DistToEdge(super::condition_nodes::numeric(a)), + "IsDeckFree" => Self::IsDeckFree, + "IsBipedCommittedToMotion" => Self::IsBipedCommittedToMotion, + "EnoughDistToObstacle" => Self::EnoughDistToObstacle { + database: a.text("db").unwrap_or("").to_owned(), + animation: a.text("anim").unwrap_or("").to_owned(), + }, + "CanBipedLand" => Self::CanBipedLand, + "IsCrouchedEnoughForBlendToGrabCycle" => Self::IsCrouchedEnoughForBlendToGrabCycle, + "TrucksOrDeckInContact" => Self::TrucksOrDeckInContact, + "PhysicsWantsManualExit" => Self::PhysicsWantsManualExit, + "ApexReached" => Self::ApexReached, + _ => unreachable!(), + } + } + pub fn evaluate(&self, host: &MotionHost) -> Result { + let p = host.gameplay_conditions.as_ref().ok_or("stock gameplay condition requires physical publication")?; + Ok(match self { + Self::CanLandOnBoard => p.can_land_on_board, + //82BA8180: no Air437/time-valid or moving-object gate. + Self::CanBipedLand => p.offboard_landing_normal[1] > 0.85, + //82BA5ED8 reads byte312 independently of held311. + Self::IsDeckFree => host.toggle_board_physical.ok_or("IsDeckFree requires completed OffBoard312")?.free_board, + //82BA80FC reads completed OffBoard329. + Self::IsBipedCommittedToMotion => p.offboard_committed_to_motion, + //82BA42E0 reads Collision3472 OR3475, excluding wheel contacts. + Self::TrucksOrDeckInContact => p.trucks_or_deck_contact, + Self::ApexReached => p.reached_apex, + //82BA8238 compares completed OffBoard116. + Self::DistToEdge(numeric) => numeric.matches(p.offboard_edge_distance), + Self::EnoughDistToObstacle { database, animation } => { + enough_distance(p.offboard_obstacle_distance, host.animation.stock_clip_translation_z(database, animation)?) + } + //82BBDE40 compares PhysOutAnimation72 with literal821EE79C. + Self::IsCrouchedEnoughForBlendToGrabCycle => host.crouching_physical + .ok_or("Crouch condition requires physical animation height")?.animation_height_72 < f32::from_bits(0x3f19_999a), + //82BA7930 reads completed Animation168. + Self::PhysicsWantsManualExit => host.manual_exit.ok_or("PhysicsWantsManualExit requires completed physical animation output")?, + }) + } +} + +fn enough_distance(distance: f32, translation: f32) -> bool { + //82BA7FF0 fadds;7FF4 fcmpu;7FF8 blt. Preserve unordered behavior. + !(distance < translation + f32::from_bits(0x3e99_999a)) +} + +#[cfg(test)] +#[path = "motion_stock_conditions/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_stock_conditions/tests.rs b/crates/skate-game/src/graph_host/motion_stock_conditions/tests.rs new file mode 100644 index 00000000..7b02def5 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_stock_conditions/tests.rs @@ -0,0 +1,57 @@ +use super::*; +use crate::graph_host::motion_animation::MotionAnimation; + +#[test] +fn obstacle_distance_uses_named_clip_displacement_and_native_boundary() { + assert!(!enough_distance(0.49, 0.2)); + assert!(enough_distance(0.5, 0.2)); + assert!(enough_distance(0.51, 0.2)); + assert!(!enough_distance(0.29, 0.0)); + assert!(enough_distance(0.3, 0.0)); + //Native fcmpu/blt; this leaf does not substitute a finite-value guard. + assert!(enough_distance(f32::NAN, 0.2)); +} + +#[test] +fn stop_animation_query_uses_database_and_ignores_event_time_gate() { + let metadata = skate_data::animation_metadata::AnimationMetadata::parse(r#"{ + "version":1,"source_bank":"OffBoard.abin", + "source_sha256":"0000000000000000000000000000000000000000000000000000000000000000", + "source_bytes":256,"clips":[{ + "name":"TEST_STOP","source_offset":48,"fps_bits":1114636288, + "frames_bits":1110704128,"base_speed_bits":1065353216,"flags_word":0, + "attributes":[{"name":"ANIMTRANSZ","type_id":0, + "begin_bits":1056964608,"end_bits":1065353216, + "payload_words":[1056964608],"source_offset":128}] + }],"unsupported_trees":[] + }"#).unwrap(); + let animation = MotionAnimation::from_metadata(metadata); + assert_eq!(animation.stock_clip_translation_z("offboard", "test_stop").unwrap(), 0.5); + assert_eq!(animation.stock_clip_translation_z("OnBoard", "TEST_STOP").unwrap(), 0.0); + assert_eq!(animation.stock_clip_translation_z("OffBoard", "MISSING_STOP").unwrap(), 0.0); +} + +#[test] +#[ignore = "requires private stock banks; named stop-clip predicate regression, not gameplay acceptance"] +fn stock_stop_clips_drive_obstacle_distance_predicate() { + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT") + .expect("Set SKATE3_ASSET_ROOT")); + let banks = skate_data::animation_banks::AnimationBanks::load(&root).unwrap(); + let metadata = banks.metadata().unwrap(); + let animation = MotionAnimation::from_metadata(banks.metadata().unwrap()); + //Every authored EnoughDistToObstacle dependency in OffBoard/intostand.xml. + for prefix in ["WALK", "RUN"] { + for phase in [0, 25, 50, 75] { + let name = format!("BR_{prefix}_FWD_{phase}_INTO_STAND_0"); + let clip = metadata.clip(&name).unwrap(); + let attribute = clip.attributes.iter().find(|a| a.name.eq_ignore_ascii_case("AnimTransZ")) + .expect("Authored stopping displacement"); + assert_eq!(attribute.type_id, 0, "{name}"); + let displacement = f32::from_bits(attribute.payload_words[0]); + assert_eq!(animation.stock_clip_translation_z("OffBoard", &name).unwrap(), displacement); + let boundary = displacement + f32::from_bits(0x3e99_999a); + assert!(enough_distance(boundary, displacement), "{name}"); + assert!(!enough_distance(boundary - 0.001, displacement), "{name}"); + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_stock_gameplay.rs b/crates/skate-game/src/graph_host/motion_stock_gameplay.rs index 074ea5cb..269f0e6a 100644 --- a/crates/skate-game/src/graph_host/motion_stock_gameplay.rs +++ b/crates/skate-game/src/graph_host/motion_stock_gameplay.rs @@ -1,5 +1,9 @@ -//! Imported grab, tweak scoring and moving-object registration operations. -//! Existing native grind, manual, landing and offboard owners take precedence. +//! Typed bindings for the remaining stock MotionGraph gameplay operations. +//! +//! These operations are deliberately represented as authored gameplay nodes, +//! not compatibility no-ops. Their producers are completed incrementally and +//! execution fails with the exact authored operation until that producer is +//! published by the corresponding physics owner. use skate_data::state_graph::attributes::Attributes; use skate_core::animation::output::attributes::AttributeName; use skate_core::animation::skeleton_input::name::encode; @@ -112,29 +116,89 @@ fn djb2(value: &str) -> u32 { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Operation { + MatchAirTime, + UpdateStandingOnCar, InitMovingObjects { path: String }, MovingObject { path: String }, + CreateGrindAttributes, + ControlGrindCrouch, + GrindControlFade, SetGrabType { grab: String }, + JumpInto { attribute: AttributeName }, + ScoringHandPlants, + SetHandPlantAnticLength, ScoringGrabs { grab_name: String, - up: Option, left: Option, down: Option, right: Option, - intent_x: String, intent_y: String, - invert_y: bool, polar: bool, + up: Option, + left: Option, + down: Option, + right: Option, + intent_x: String, + intent_y: String, + invert_y: bool, + polar: bool, }, + AirDismounting, TweakProject { - intent_x: String, intent_y: String, - attribute_x: AttributeName, attribute_y: AttributeName, + intent_x: String, + intent_y: String, + attribute_x: AttributeName, + attribute_y: AttributeName, }, + FootPlantAbsorb, + FingerFlipOut, + EnterSkitchingBehaviour, + SkitchingBehaviour, + SkitchShimmyingBehaviour, + SetManualAngle { attribute: AttributeName }, + HippyJumpAntic, + LandOnBoard, } + impl Operation { pub fn recognizes(name: &str) -> bool { - matches!(name, "InitMovingObjects" | "MovingObject" | "SetGrabType" | "ScoringGrabs" | "TweakProject") + matches!( + name, + "MatchAirTime" + | "UpdateStandingOnCar" + | "InitMovingObjects" + | "MovingObject" + | "CreateGrindAttributes" + | "ControlGrindCrouch" + | "GrindControlFade" + | "SetGrabType" + | "JumpInto" | "ScoringHandPlants" | "SetHandPlantAnticLength" + | "ScoringGrabs" | "AirDismounting" | "TweakProject" + | "FootPlantAbsorb" | "FingerFlipOut" | "EnterSkitchingBehaviour" + | "SkitchingBehaviour" | "SkitchShimmyingBehaviour" + | "SetManualAngle" | "HippyJumpAntic" + | "LandOnBoard" + ) } + pub fn parse(a: &Attributes<'_>) -> Self { match a.text("name").unwrap_or("") { - "InitMovingObjects" => Self::InitMovingObjects { path: authored_path(a) }, - "MovingObject" => Self::MovingObject { path: authored_path(a) }, + "MatchAirTime" => Self::MatchAirTime, + "UpdateStandingOnCar" => Self::UpdateStandingOnCar, + "InitMovingObjects" => Self::InitMovingObjects { + path: authored_path(a), + }, + "MovingObject" => Self::MovingObject { + path: authored_path(a), + }, + "CreateGrindAttributes" => Self::CreateGrindAttributes, + "ControlGrindCrouch" => Self::ControlGrindCrouch, + "GrindControlFade" => Self::GrindControlFade, "SetGrabType" => Self::SetGrabType { grab: a.text("grab").unwrap_or("").to_owned() }, + "JumpInto" => Self::JumpInto { + attribute: encode( + a.text("attribute") + .or_else(|| a.text("attr")) + .unwrap_or("JumpInto") + .as_bytes(), + ), + }, "ScoringHandPlants" => Self::ScoringHandPlants, + "SetHandPlantAnticLength" => Self::SetHandPlantAnticLength, "ScoringGrabs" => Self::ScoringGrabs { grab_name: required_text(a, "grabName"), up: a.text("up").map(str::to_owned), @@ -145,11 +209,23 @@ impl Operation { intent_y: a.text("intentY").or_else(|| a.text("magnitude")).unwrap_or("").to_owned(), invert_y: a.boolean_byte("invertY", 0) != 0, polar: a.text("intentX").is_none(), - }, + }, "AirDismounting" => Self::AirDismounting, "TweakProject" => Self::TweakProject { - intent_x: "TweakX".to_owned(), intent_y: "TweakY".to_owned(), - attribute_x: encode(b"tweak_x"), attribute_y: encode(b"tweak_y"), + // TU3 factory 82BC9510 binds these names directly. + intent_x: "TweakX".to_owned(), + intent_y: "TweakY".to_owned(), + attribute_x: encode(b"tweak_x"), + attribute_y: encode(b"tweak_y"), + }, "FootPlantAbsorb" => Self::FootPlantAbsorb, + "FingerFlipOut" => Self::FingerFlipOut, + "EnterSkitchingBehaviour" => Self::EnterSkitchingBehaviour, + "SkitchingBehaviour" => Self::SkitchingBehaviour, + "SkitchShimmyingBehaviour" => Self::SkitchShimmyingBehaviour, + "SetManualAngle" => Self::SetManualAngle { + // Factory82BC9678/84 uses literal8231D5C8, no authored override. + attribute: encode(b"manual_angle"), }, + "HippyJumpAntic" => Self::HippyJumpAntic, "LandOnBoard" => Self::LandOnBoard, _ => unreachable!("unrecognized stock gameplay operation"), } } diff --git a/crates/skate-game/src/graph_host/motion_toggle_execute.rs b/crates/skate-game/src/graph_host/motion_toggle_execute.rs new file mode 100644 index 00000000..91c8ca9e --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_toggle_execute.rs @@ -0,0 +1,26 @@ +//! Persistent ToggleBoard instance and shared animation-owner dispatch. +use super::*; +impl MotionHost { + pub(super) fn execute_toggle_board(&mut self, behavior: BehaviorId, phase: u8) -> Result<(), String> { + let Instance::ToggleBoard(state) = self.instances.get_mut(behavior) + .ok_or("Unallocated ToggleBoard behavior")? else { + return Err("ToggleBoard operation/instance mismatch".into()); + }; + let mirrored = self.animation.skater_animation_flags + .map(|flags| flags & 0x4000_0000 != 0); + //82BA9060/9080 reads Actor+20 virtual16, not raw AG input. + //8259176C binds Actor+20 to IMotionGraph (8258F180); vtable + //823006D0+16 ->8258F330 returns MG+12, its authored intention map. + //The stock OffBoard AG state controls when these requests exist. + let intents = &self.animation.motion_intents; + let board = super::super::outputs::BoardControls { + drop_requested: intents.contains_key("OB_DropBoard"), + throw_requested: intents.contains_key("OB_ThrowBoard"), + retrieve_requested: intents.contains_key("OB_RetrieveBoard"), + }; + super::super::motion_toggle_board::execute( + state, &mut self.animation, board, + self.toggle_board_physical, mirrored, phase, + ) + } +} diff --git a/crates/skate-game/src/graph_host/motion_tricks.rs b/crates/skate-game/src/graph_host/motion_tricks.rs index a0d7b128..38e519fa 100644 --- a/crates/skate-game/src/graph_host/motion_tricks.rs +++ b/crates/skate-game/src/graph_host/motion_tricks.rs @@ -169,7 +169,8 @@ pub fn execute( } Operation::Scoring(name) if phase == 1 => { // ISkaterMotionGraph236 ->8258FA20 stores both equal names and bit24. - host.score_packet.trick = Some(name); + host.score_packet.trick_names.first = Some(name); + host.score_packet.trick_names.second = Some(name); host.score_packet.flags |= 0x0100_0000; } Operation::MonitorUnderflip => { diff --git a/crates/skate-game/src/graph_host/motion_wipeout_execute.rs b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs index 623c79ca..ee15ead9 100644 --- a/crates/skate-game/src/graph_host/motion_wipeout_execute.rs +++ b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs @@ -61,15 +61,15 @@ impl MotionHost { state.gesture = if state.released { self.wipeout_controls.seed_from_air_tweak = false; [ - *self.action_intents.get("OB_AirBodyTweakX").unwrap_or(&0.0), - *self.action_intents.get("OB_AirBodyTweakY").unwrap_or(&0.0), + self.action_controls.wipeout.air_body_tweak[0], + self.action_controls.wipeout.air_body_tweak[1], ] } else { [0.0; 2] }; return Ok(()); } - if self.action_intents.contains_key("WipeOutRequest") || (state.ticks as i32) > 80 { + if self.action_controls.wipeout.request || (state.ticks as i32) > 80 { state.released = true; } if self.wipeout_controls.gestures_enabled { @@ -121,8 +121,9 @@ impl MotionHost { state.twist_velocity = velocity; state.twist = positive_turn(twist); set(&mut self.animation, b"Twist", state.twist); - let x = self.action_intents.get("WipeoutGestureX").copied(); - let y = self.action_intents.get("WipeoutGestureY").copied(); + let gesture = self.action_controls.wipeout.gesture; + let x = gesture.map(|values| values[0]); + let y = gesture.map(|values| values[1]); let xv = x.unwrap_or(0.0); let yv = y.unwrap_or(0.0); if !state.released && x.is_some() && y.is_some() && xv.abs() < 0.1 && yv.abs() < 0.1 { diff --git a/crates/skate-game/src/graph_host/tests/animation_jump_into.rs b/crates/skate-game/src/graph_host/tests/animation_jump_into.rs new file mode 100644 index 00000000..535974e3 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/animation_jump_into.rs @@ -0,0 +1,68 @@ +use super::*; + +fn owner(tree: PlaybackTree) -> MotionAnimation { + // Synthetic independent fixture; no claim that this is stock animation data. + let metadata = AnimationMetadata::parse(r#"{ + "version":1,"source_bank":"synthetic-test", + "source_sha256":"0000000000000000000000000000000000000000000000000000000000000000", + "source_bytes":48,"clips":[],"unsupported_trees":[] + }"#).unwrap(); + let mut animation = MotionAnimation::from_metadata(metadata); + animation.current = Some(tree); + animation +} + +fn clip(begin: f32, blend_value: f32) -> PlaybackTree { + PlaybackTree::Clip { + name: "synthetic-manual-takeoff".into(), + clip: PlaybackClip::new(61.0, 60.0, 1.0, 0, vec![ + ClipAttribute { + name: encode(b"manualInto"), kind: 0, begin, end: begin + 0.1, + payload: vec![17.0f32.to_bits()], + }, + ClipAttribute { + name: encode(b"height"), kind: 0, begin: -1.0, end: -1.0, + payload: vec![blend_value.to_bits()], + }, + ]), + } +} + +#[test] +fn jump_into_queries_inactive_marker_begin_and_resets_clip_history() { + let mut animation = owner(clip(0.625, 0.0)); + let name = encode(b"manualInto"); + animation.refresh_tree_attributes().unwrap(); + assert!(!animation.tree_attributes().iter().any(|a| a.name == name)); + animation.jump_into(name).unwrap(); + assert_eq!(animation.current_time().unwrap(), 0.625); + let PlaybackTree::Clip { clip, .. } = animation.current.as_ref().unwrap() else { panic!() }; + assert_eq!(clip.clock.previous_time, 0.625); + assert_eq!(clip.clock.loops_since_evaluation, 0); + assert!(animation.motion_attributes.is_empty()); + assert!(animation.settable.entries().is_empty()); +} + +#[test] +fn jump_into_applies_pending_blend_before_reading_marker_time() { + let tree = PlaybackTree::PhaseBlend(PhaseBlend::new( + encode(b"height"), vec![clip(0.25, 0.0), clip(0.75, 1.0)], + ).unwrap()); + let mut animation = owner(tree); + animation.set_attribute(SettableAttribute { + name: encode(b"height"), value: 1.0, normalized: false, sequence_id: -1, + }); + animation.jump_into(encode(b"manualInto")).unwrap(); + assert_eq!(animation.current_time().unwrap(), 0.75); + assert!(animation.settable.entries().is_empty()); + assert!(animation.motion_attributes.is_empty()); +} + +#[test] +fn jump_into_missing_marker_preserves_time_without_emitting_packets() { + let mut animation = owner(clip(0.625, 0.0)); + animation.advance(0.125, 0.0); + animation.jump_into(encode(b"missing-marker")).unwrap(); + assert_eq!(animation.current_time().unwrap(), 0.125); + assert!(animation.motion_attributes.is_empty()); +} diff --git a/crates/skate-game/src/graph_host/tests/motion_jump_into.rs b/crates/skate-game/src/graph_host/tests/motion_jump_into.rs new file mode 100644 index 00000000..5251d218 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_jump_into.rs @@ -0,0 +1,68 @@ +use super::*; + +#[test] +#[ignore = "requires private stock graphs and animation banks"] +fn stock_jump_into_seeks_once_per_entry_and_consumes_misses() { + use crate::graph_runtime::CompiledGraph; + use skate_core::animation::playback::{PlaybackRequest, PlaybackService, TransitionSettings}; + use skate_data::state_graph::{StateGraph, binding::Binding}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let source = StateGraph::load(&root.join( + "private/stock/data/state/MotionGraph_OnBoard.stategraph", + )).unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + let graph = LoadedGraph { source, binding, runtime }; + let mut host = MotionHost::from_graph( + &graph, &Collections::load(&root).unwrap(), + skate_data::animation_banks::AnimationBanks::load(&root).unwrap().metadata().unwrap(), + PlaybackContext { is_switch: Some(false), is_mirrored: Some(false), + board_available: Some(true), pro_skater: encode(b""), transition_override: None }, + ).unwrap(); + host.animation.skater_animation_flags = Some(0); + let behavior = host.remap.behaviors.iter().position(|&id| matches!( + &host.operations[id], MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::JumpInto { attribute } + ) if *attribute == encode(b"manualInto") + )).unwrap(); + let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: vec![] }; + let request = PlaybackRequest { + animation: "B_HARDFLIP_G".into(), speed: 1.0, start_time: 0.0, + transition: TransitionSettings { kind: 1, seconds: 0.0, under: 0, + matching: 0, use_channels_from_weights: false }, + }; + let expected = host.animation.build_tree("B_HARDFLIP_G").unwrap() + .attribute(encode(b"manualInto"), 31).unwrap().unwrap().begin_time; + assert!(expected.is_finite(), "stock manualInto marker must have a finite begin time"); + for _ in 0..2 { + host.animation.play(request.clone()).unwrap(); + let handle = host.allocate(behavior, &frame); + host.execute(behavior, &frame, 0).unwrap(); + assert_eq!(host.animation.current_time().unwrap(), 0.0); + host.execute(behavior, &frame, 1).unwrap(); + assert_eq!(host.animation.current_time().unwrap(), expected); + host.animation.advance(frame.dt, 0.0); + let advanced = host.animation.current_time().unwrap(); + host.execute(behavior, &frame, 1).unwrap(); + assert_eq!(host.animation.current_time().unwrap(), advanced); + host.execute(behavior, &frame, 2).unwrap(); + host.release(handle); + assert!(host.animation.motion_attributes.is_empty()); + } + // A first-update miss also consumes the native instance latch. + let op = host.remap.behaviors[behavior]; + host.operations[op] = MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::JumpInto { + attribute: encode(b"missing-marker"), + }, + ); + host.animation.play(request).unwrap(); + host.allocate(behavior, &frame); + host.execute(behavior, &frame, 1).unwrap(); + assert!(matches!(host.instances[behavior], Instance::JumpInto { first_update: false })); + host.operations[op] = MotionOperation::StockGameplay( + super::super::motion_stock_gameplay::Operation::JumpInto { attribute: encode(b"manualInto") }, + ); + host.execute(behavior, &frame, 1).unwrap(); + assert_eq!(host.animation.current_time().unwrap(), 0.0); +} diff --git a/crates/skate-game/src/grind_world.rs b/crates/skate-game/src/grind_world.rs index a985d24e..ab4de041 100644 --- a/crates/skate-game/src/grind_world.rs +++ b/crates/skate-game/src/grind_world.rs @@ -103,9 +103,28 @@ fn box_faces(out: &mut Vec<[Vector3; 4]>, min: Vector3, max: Vector3) { } mod spline; -pub(crate) use spline::primitives; +mod provider; +mod octree; +pub(crate) use spline::{primitives, PrimitiveMetadata}; +pub(crate) use provider::{SourceIdentity, StaticProvider}; -/// Relocatable Pegasus tSplineData used by the live default-world query. +/// Retain the archive's relocatable Pegasus representation, including all 120 +/// authored bytes per segment. Omitted runtime pointers are rebuilt as offsets. +pub(crate) fn native_blob( + map: Option<&skate_data::skate_map::SkateMap>, +) -> Result, String> { + spline::build(map) +} + +#[cfg(test)] +mod tests; + pub(crate) fn spline_blob() -> Vec { - spline::build(None).expect("static test-course splines are valid") + spline::build_rails(&test_rails()).expect("authored course splines") +} +pub(super) fn test_rails() -> Vec { + rails().iter().map(|r| skate_data::skate_map::Rail { + name: r.name.into(), points: vec![[r.start.x,r.start.y,r.start.z], [r.end.x,r.end.y,r.end.z]], + closed: false, native: None, + }).collect() } diff --git a/crates/skate-game/src/grind_world/octree.rs b/crates/skate-game/src/grind_world/octree.rs new file mode 100644 index 00000000..ac89c109 --- /dev/null +++ b/crates/skate-game/src/grind_world/octree.rs @@ -0,0 +1,150 @@ +//! Original S3 loose octree insertion/query order, 82AD6830/82476158. +//! Host-owned nodes retain native head insertion, split redistribution and +//! LIFO candidate traversal because result capacity is observably bounded. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Bounds { pub min: [f32; 3], pub max: [f32; 3] } +impl Bounds { + /// 8258C1E0 still executes center/half-extent arithmetic when the supplied + /// transform is identity. Do not optimize it to copying the authored box. + pub fn identity_transformed(self) -> Self { + let center: [f32; 3] = std::array::from_fn(|i| (self.max[i]+self.min[i])*0.5); + let half: [f32; 3] = std::array::from_fn(|i| (self.max[i]-self.min[i])*0.5); + Self { min: std::array::from_fn(|i| center[i]-half[i]), + max: std::array::from_fn(|i| center[i]+half[i]) } + } + pub fn overlaps(self, b: Self) -> bool { + (0..3).all(|i| self.min[i] <= b.max[i] && b.min[i] <= self.max[i]) + } + pub fn union(self, b: Self) -> Self { + Self { min: std::array::from_fn(|i| self.min[i].min(b.min[i])), + max: std::array::from_fn(|i| self.max[i].max(b.max[i])) } + } + pub fn padded(self) -> Self { + Self { min: self.min.map(|v| v-1.), max: self.max.map(|v| v+1.) } + } + fn cube(self) -> Self { + let half: [f32; 3] = std::array::from_fn(|i| (self.max[i]-self.min[i])*0.5); + let radius = half[0].max(half[1]).max(half[2]); + let center: [f32; 3] = std::array::from_fn(|i| (self.max[i]+self.min[i])*0.5); + Self { min: center.map(|v| v-radius), max: center.map(|v| v+radius) } + } + // 82F838B0 loads 82098E30 = BECC_CCCD into 830BDD20: -0.4. + fn inner(self) -> Self { + let padding: [f32; 3] = std::array::from_fn(|i| (self.max[i]-self.min[i])*f32::from_bits(0xbecc_cccd)); + Self { min: std::array::from_fn(|i| self.min[i]-padding[i]), + max: std::array::from_fn(|i| self.max[i]+padding[i]) } + } + fn child(self, index: usize) -> Self { + let inner = self.inner(); + Self { min: std::array::from_fn(|i| if index & (1< Option<(usize, Self)> { + let inner = self.inner(); + let mut index = 0; + for i in 0..3 { + let low_margin = entry.min[i]-inner.min[i]; + let high_margin = inner.max[i]-entry.max[i]; + if low_margin <= 0. && high_margin <= 0. { return None; } + if low_margin > high_margin { index |= 1< bool { + let inner = self.inner(); + (0..3).all(|i| inner.max[i] > entry.max[i] || entry.min[i] > inner.min[i]) + } +} + +#[derive(Default)] +struct Bucket { entries: Vec<(usize, bool)>, child: Option } +struct Node { bounds: Bounds, resident: Vec, buckets: [Bucket; 8] } +impl Node { + fn new(bounds: Bounds) -> Self { Self { bounds, resident: Vec::new(), buckets: std::array::from_fn(|_| Bucket::default()) } } +} +pub(super) struct Octree { nodes: Vec, bounds: Vec, node_capacity: usize } +impl Octree { + pub fn new(asset_bounds: Bounds, bounds: Vec) -> Result { + // Native entries and node links are uint16; 0xffff is the sentinel. + if bounds.len() > 0xffff { return Err("Native grind octree exceeds uint16 entry capacity".into()); } + let mut tree = Self { node_capacity: bounds.len()/2+1, nodes: vec![Node::new(asset_bounds.cube())], bounds }; + for index in 0..tree.bounds.len() { tree.insert(index); } + Ok(tree) + } + fn insert(&mut self, entry: usize) { + let mut at = 0; + loop { + let node_bounds = self.nodes[at].bounds; + let target = if (0..3).all(|i| self.bounds[entry].min[i] >= node_bounds.min[i] + && self.bounds[entry].max[i] <= node_bounds.max[i]) { + node_bounds.containing_child(self.bounds[entry]) + } else { None }; + let Some((slot, child_bounds)) = target else { + self.nodes[at].resident.push(entry); + return; + }; + if let Some(child) = self.nodes[at].buckets[slot].child { at = child; continue; } + let movable = child_bounds.movable(self.bounds[entry]); + let bucket = &mut self.nodes[at].buckets[slot]; + bucket.entries.push((entry, movable)); + if bucket.entries.iter().filter(|(_, movable)| *movable).count() > 3 + && self.nodes.len() < self.node_capacity { + self.split(at, slot, child_bounds); + } + return; + } + } + /// 82AD65D8: walk old head-first list and prepend each redistributed entry; + /// no recursive split occurs during this redistribution. + fn split(&mut self, parent: usize, slot: usize, bounds: Bounds) { + let entries = std::mem::take(&mut self.nodes[parent].buckets[slot].entries); + let child = self.nodes.len(); + self.nodes[parent].buckets[slot].child = Some(child); + let mut node = Node::new(bounds); + for (entry, movable) in entries.into_iter().rev() { + if movable { + let (slot, next_bounds) = bounds.containing_child(self.bounds[entry]) + .expect("native movable predicate guarantees a containing child"); + node.buckets[slot].entries.push((entry, next_bounds.movable(self.bounds[entry]))); + } else { node.resident.push(entry); } + } + self.nodes.push(node); + } + /// 82AD6D18 pushes resident then ascending leaf lists; 82476158 consumes + /// those lists LIFO before taking descending deferred child nodes. + pub fn query(&self, query: Bounds, limit: usize) -> Vec { + let mut result = Vec::new(); + if limit == 0 { return result; } + let mut stack = vec![0]; + while let Some(index) = stack.pop() { + let node = &self.nodes[index]; + // Buckets retain movable bits; traverse directly in the same order + // instead of manufacturing a second copy of every entry list. + let mut leaf_slots = Vec::new(); + for slot in 0..8 { + if node.bounds.child(slot).overlaps(query) { + if let Some(child) = node.buckets[slot].child { stack.push(child); } + else { leaf_slots.push(slot); } + } + } + for slot in leaf_slots.into_iter().rev() { + for &(entry, _) in node.buckets[slot].entries.iter().rev() { + if self.bounds[entry].overlaps(query) { + result.push(entry); + if result.len() == limit { return result; } + } + } + } + for &entry in node.resident.iter().rev() { + if self.bounds[entry].overlaps(query) { + result.push(entry); + if result.len() == limit { return result; } + } + } + } + result + } +} diff --git a/crates/skate-game/src/grind_world/provider.rs b/crates/skate-game/src/grind_world/provider.rs new file mode 100644 index 00000000..9f6901c0 --- /dev/null +++ b/crates/skate-game/src/grind_world/provider.rs @@ -0,0 +1,194 @@ +//! Static stock assets only. Registration follows package source occurrence +//! order; native octree order is retained within each registered section. +use super::{octree::{Bounds, Octree}, spline}; +use skate_core::physics::grind_contact::Primitive; +use skate_data::skate_map::SkateMap; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SourceIdentity { + pub stream_file: String, + pub asset_id: String, + pub section_index: u64, + pub section_offset: u64, +} +struct Asset { + source: SourceIdentity, + bounds: Bounds, + indices: Vec, + tree: Octree, +} +pub(crate) struct StaticProvider { + primitives: Vec, + metadata: Vec, + rail_guids: Vec<[u64; 2]>, + assets: Vec, + /// Parallel to primitives; no endpoint-derived replacement boxes. + authored_bounds: Vec, + source_for_primitive: Vec, + source_rail_indices: Vec, +} + +impl StaticProvider { + /// WMET preserves source section identities even when spline IDs repeat. + /// Missing provenance is an explicit conversion prerequisite, not a ray miss. + pub fn new(map: Option<&SkateMap>) -> Result { + let Some(map) = map else { + return Ok(Self { primitives: vec![], metadata: vec![], rail_guids: vec![], assets: vec![], authored_bounds: vec![], source_for_primitive: vec![], source_rail_indices: vec![] }); + }; + if map.rails.is_empty() { + return Ok(Self { primitives: vec![], metadata: vec![], rail_guids: vec![], assets: vec![], authored_bounds: vec![], source_for_primitive: vec![], source_rail_indices: vec![] }); + } + if map.rails.iter().all(|rail| rail.native.is_none()) { return Self::authored(&map.rails); } + let mut metadata = map.extensions.iter().filter(|e| e.tag == *b"WMET"); + let extension = metadata.next().ok_or("Stock grind provider requires WMET source identity metadata")?; + if extension.schema != 1 || metadata.next().is_some() { return Err("Expected exactly one WMET schema 1 extension".into()); } + let manifest: Value = serde_json::from_slice(&extension.payload).map_err(|e| format!("Invalid WMET: {e}"))?; + if manifest["grind_coordinate_policy"]["mode"].as_str() != Some("world_space") { + return Err("Static grind provider requires verified world_space spline payloads".into()); + } + let records = manifest["grind_splines"].as_array().ok_or("WMET grind_splines array missing")?; + if records.len() != map.rails.len() { return Err("WMET/package rail count mismatch".into()); } + let bytes = spline::build(Some(map))?; + let (primitives, metadata) = spline::decoded_from_blob(&bytes)?; + let word = |at: usize| u32::from_be_bytes(bytes[at..at+4].try_into().unwrap()); + let mut grouped: Vec<(SourceIdentity, Vec)> = Vec::new(); + let mut source_index = HashMap::new(); + let mut authored_bounds = Vec::with_capacity(primitives.len()); + let mut spatial_bounds = Vec::with_capacity(primitives.len()); + let mut source_for_primitive = Vec::with_capacity(primitives.len()); + let mut source_rail_indices = Vec::with_capacity(primitives.len()); + let mut rail_guids = Vec::with_capacity(map.rails.len()); + let mut ordinal = 0; + for (rail, (record, package_rail)) in records.iter().zip(&map.rails).enumerate() { + if package_rail.native.is_none() { return Err(format!("Stock rail {} lacks native cubic payload", package_rail.name)); } + let source = SourceIdentity { + stream_file: string(record, "stream_file")?, asset_id: string(record, "asset_id")?, + section_index: number(record, "section_index")?, section_offset: number(record, "section_offset")?, + }; + let source_rail = number(record, "rail_index")?; + let expected_name = format!("{}_{}_{}", source.asset_id, source.section_index, source_rail); + if package_rail.name != expected_name { return Err(format!("Stock rail provenance/name mismatch: {}", package_rail.name)); } + let header = 16+rail*32; + let first = word(header+20) as usize; + let last = word(header+24) as usize; + let count = (last-first)/144+1; + let spline_id = ((word(header) as u64)<<32)|word(header+4) as u64; + let type_signature = ((word(header+8) as u64)<<32)|word(header+12) as u64; + rail_guids.push([spline_id, type_signature]); + if parse_id(record, "spline_id")? != spline_id || number(record, "segment_count")? != count as u64 + || parse_id(record, "type_signature")? != type_signature + || number(record, "flags")? != word(header+16) as u64 + || number(record, "trailing_word")? != word(header+28) as u64 + || record["closed"].as_bool() != Some(package_rail.closed) { + return Err(format!("Stock rail native identity/count mismatch: {}", package_rail.name)); + } + let asset = *source_index.entry(source.clone()).or_insert_with(|| { + let index = grouped.len(); grouped.push((source, Vec::new())); index + }); + for segment in 0..count { + let at = first+144*segment; + let bounds = Bounds { + min: std::array::from_fn(|i| f32::from_bits(word(at+80+i*4))), + max: std::array::from_fn(|i| f32::from_bits(word(at+96+i*4))), + }; + authored_bounds.push(bounds); + spatial_bounds.push(bounds.identity_transformed()); + grouped[asset].1.push(ordinal); + source_for_primitive.push(asset); + source_rail_indices.push(source_rail); + ordinal+=1; + } + } + let assets = grouped.into_iter().map(|(source, indices)| { + let bounds = indices.iter().map(|&i| spatial_bounds[i]).reduce(Bounds::union) + .ok_or("Empty stock grind source section")?.padded(); + let tree = Octree::new(bounds, indices.iter().map(|&i| spatial_bounds[i]).collect())?; + Ok(Asset { source, bounds, indices, tree }) + }).collect::>()?; + Ok(Self { primitives, metadata, rail_guids, assets, authored_bounds, source_for_primitive, source_rail_indices }) + } + + /// Explicit host-authored polylines have no retail source section identity. + pub fn authored(rails: &[skate_data::skate_map::Rail]) -> Result { + let bytes = spline::build_rails(rails)?; + let (primitives, metadata) = spline::decoded_from_blob(&bytes)?; + let rail_guids = (0..rails.len()).map(|i| { + let at = 16 + i * 32; + [u64::from_be_bytes(bytes[at..at+8].try_into().unwrap()), + u64::from_be_bytes(bytes[at+8..at+16].try_into().unwrap())] + }).collect(); + let authored_bounds: Vec<_> = primitives.iter().map(|p| Bounds { + min: std::array::from_fn(|i| p.start[i].min(p.end[i])), + max: std::array::from_fn(|i| p.start[i].max(p.end[i])), + }).collect(); + let assets = if let Some(bounds) = authored_bounds.iter().copied().reduce(Bounds::union) { + let bounds = bounds.padded(); + vec![Asset { + source: SourceIdentity { stream_file: String::new(), asset_id: "host-authored".into(), section_index: 0, section_offset: 0 }, + bounds, indices: (0..primitives.len()).collect(), + tree: Octree::new(bounds, authored_bounds.clone())?, + }] + } else { vec![] }; + let source_rail_indices = primitives.iter().map(|p| p.owner - 1).collect(); + Ok(Self { source_for_primitive: vec![0; primitives.len()], primitives, metadata, + rail_guids, assets, authored_bounds, source_rail_indices }) + } + + pub fn primitives(&self) -> &[Primitive] { &self.primitives } + + pub fn metadata(&self, primitive: usize) -> Option<&spline::PrimitiveMetadata> { + self.metadata.get(primitive) + } + + /// Resolve contact's map-local header handle without confusing it with GUID. + pub fn spline_guids(&self, owner: u64) -> Option<[u64; 2]> { + let rail = usize::try_from(owner.checked_sub(1)?).ok()?; + self.rail_guids.get(rail).copied() + } + + pub fn source_rail_index(&self, primitive: usize) -> Option { + self.source_rail_indices.get(primitive).copied() + } + + pub fn source(&self, primitive: usize) -> Option<&SourceIdentity> { + self.source_for_primitive.get(primitive).map(|&asset| &self.assets[asset].source) + } + + pub fn bounds(&self, primitive: usize) -> Option<([f32; 3], [f32; 3])> { + self.authored_bounds.get(primitive).map(|b| (b.min, b.max)) + } + + /// S3 82C1EAD8 static pass only: query gate, ordered asset overlap and + /// native per-asset octree traversal, at most forty original vector indices. + /// Does not implement or pretend to query unavailable moving providers. + pub fn query(&self, min: [f32; 3], max: [f32; 3]) -> Result, String> { + if (0..3).any(|i| !min[i].is_finite() || !max[i].is_finite() || min[i] > max[i]) { + return Err("Invalid grind query bounds".into()); + } + let query = Bounds { min, max }; + let delta: [f32; 3] = std::array::from_fn(|i| min[i]-max[i]); + let square = (delta[0]*delta[0]+delta[1]*delta[1])+delta[2]*delta[2]; + if !(square > f32::from_bits(0x3780_0000)) { return Ok(vec![]); } + let mut result = Vec::new(); + for asset in &self.assets { + if !asset.bounds.overlaps(query) { continue; } + for local in asset.tree.query(query, 40-result.len()) { result.push(asset.indices[local]); } + if result.len() == 40 { break; } + } + Ok(result) + } +} + +fn string(value: &Value, key: &str) -> Result { + value[key].as_str().map(str::to_owned).ok_or_else(|| format!("WMET rail missing string {key}")) +} +fn number(value: &Value, key: &str) -> Result { + value[key].as_u64().ok_or_else(|| format!("WMET rail missing unsigned {key}")) +} +fn parse_id(value: &Value, key: &str) -> Result { + let value = string(value, key)?; + u64::from_str_radix(value.strip_prefix("0x").unwrap_or(&value), 16) + .map_err(|_| format!("Invalid WMET {key}")) +} diff --git a/crates/skate-game/src/grind_world/spline.rs b/crates/skate-game/src/grind_world/spline.rs index 6c1c6268..4d9d0f94 100644 --- a/crates/skate-game/src/grind_world/spline.rs +++ b/crates/skate-game/src/grind_world/spline.rs @@ -1,18 +1,30 @@ -//! SK8R15 owned/world/src/grind_spline.cpp and TU3 AssetRecord82C1E568. +//! User-authorized ZIP spline adapter, checked against original TU3 82C1E568, +//! 82C1E6C0, 82C1EEF0 and 82C1F098 in default.patched.xex (431b8eba...). //! Keep the native cubic payload; the game's contact primitive is its chord //! D -> (A+B)+(C+D), including for retail cubic segments. use skate_core::physics::grind_contact::Primitive; use skate_data::skate_map::{Rail, SkateMap}; +/// Native primitive side metadata; contact's endpoint/owner API stays unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PrimitiveMetadata { + /// Both authored header u64s, +0 and +8. The extractor names the latter + /// `type_signature`; preserve it verbatim, not the containing asset ID. + pub spline_guids: [u64; 2], + pub segment_index: u32, + /// 82C1E568 computed near-vertical high bit; newly allocated lower bits zero. + pub flags: u32, +} + pub(super) fn build(map: Option<&SkateMap>) -> Result, String> { - let defaults; - let rails = if let Some(map) = map { &map.rails } else { - defaults = super::rails().iter().map(|r| Rail { - name: r.name.into(), closed: false, native: None, - points: vec![[r.start.x,r.start.y,r.start.z], [r.end.x,r.end.y,r.end.z]], - }).collect(); - &defaults - }; + build_rails(map.map(|map| map.rails.as_slice()).unwrap_or(&[])) +} + +pub(super) fn build_rails(rails: &[Rail]) -> Result, String> { + // TU3 82C1EEF0 reads the rail count with lhz +2, not a full 32-bit load. + if rails.len() > usize::from(u16::MAX) { + return Err("Pegasus spline table exceeds its uint16 rail count".into()); + } let mut records = Vec::new(); for rail in rails { let mut header = [0u32; 6]; @@ -41,8 +53,8 @@ pub(super) fn build(map: Option<&SkateMap>) -> Result, String> { header = [(id>>32) as u32,id as u32,0x2c701707,0x0007004a,0,0]; let mut points = rail.points.clone(); let first = points[0]; let last = *points.last().unwrap(); - let distance: f32 = (0..3).map(|i| (first[i]-last[i]).powi(2)).sum(); - if rail.closed && distance > 0.001*0.001 { points.push(first); } + // Closed authored polylines retain their final edge, even when short. + if rail.closed && first != last { points.push(first); } for pair in points.windows(2) { let [start,end] = [pair[0],pair[1]]; let mut segment = [0u32;30]; @@ -63,12 +75,18 @@ pub(super) fn build(map: Option<&SkateMap>) -> Result, String> { if s[..28].iter().any(|w| !f32::from_bits(*w).is_finite()) { return Err(format!("Non-finite spline data in {}",rail.name)); } + if (0..3).any(|i| f32::from_bits(s[20+i]) > f32::from_bits(s[24+i])) { + return Err(format!("Inverted spline bounds in {}", rail.name)); + } } records.push((header,segments)); } - let count: usize = records.iter().map(|r| r.1.len()).sum(); + let count = records.iter().try_fold(0usize, |count, r| count.checked_add(r.1.len())) + .ok_or("Spline segment count overflow")?; let base = 16+records.len()*32; - let mut bytes = vec![0; base+count*144]; + let size = count.checked_mul(144).and_then(|size| size.checked_add(base)) + .filter(|&size| u32::try_from(size).is_ok()).ok_or("Spline blob exceeds uint32 offsets")?; + let mut bytes = vec![0; size]; put(&mut bytes,0,records.len() as u32); put(&mut bytes,4,count as u32); put(&mut bytes,8,16); put(&mut bytes,12,base as u32); let mut at = base; @@ -91,8 +109,19 @@ pub(super) fn build(map: Option<&SkateMap>) -> Result, String> { pub(crate) fn primitives(map: Option<&SkateMap>) -> Result,String> { let bytes = build(map)?; + primitives_from_blob(&bytes) +} + +// Only accepts a blob returned by build/build_rails; public package input is +// validated there. Owner is a map-local header handle, not the repeating ID. +pub(super) fn primitives_from_blob(bytes: &[u8]) -> Result,String> { + decoded_from_blob(bytes).map(|(primitives, _)| primitives) +} + +pub(super) fn decoded_from_blob(bytes: &[u8]) -> Result<(Vec, Vec),String> { let word = |at| u32::from_be_bytes(bytes[at..at+4].try_into().unwrap()); let mut result = Vec::new(); + let mut metadata = Vec::new(); let base = word(12) as usize; for i in 0..word(4) as usize { let s = base+i*144; @@ -105,17 +134,22 @@ pub(crate) fn primitives(map: Option<&SkateMap>) -> Result,String if !length.is_finite() { return Err(format!("Spline segment {i} has a non-finite contact chord")); } - if length <= 0.000001 { - // Retail Downtown contains duplicate knots and sub-millimetre - // chords. They cannot provide a usable contact direction. Keep - // their cubic records and links in build(), but omit them from - // this adapter's independent contact-primitive array. - continue; - } - result.push(Primitive { start:d,end,owner:((word(r) as u64)<<32)|word(r+4) as u64 }); + // Original 82C1E568 / 82C1F098 emit every segment. Do not drop tiny or + // duplicate knots here: downstream contact/selection owns admission. + let horizontal_tolerance = f32::from_bits(0x3780_0000); + let vertical = (end[0]-d[0]).abs() <= horizontal_tolerance + && (end[2]-d[2]).abs() <= horizontal_tolerance; + result.push(Primitive { + start: d, end, + owner: ((r-16)/32 + 1) as u64, + }); + metadata.push(PrimitiveMetadata { + spline_guids: [((word(r) as u64)<<32)|word(r+4) as u64, + ((word(r+8) as u64)<<32)|word(r+12) as u64], + segment_index: ((s-word(r+20) as usize)/144) as u32, + flags: if vertical { 0x8000_0000 } else { 0 }, + }); } - bevy::log::info!("GRIND_RUNTIME splines={} primitives={} source={}",word(0),result.len(), - map.map(|m|m.name.as_str()).unwrap_or("default test world")); - Ok(result) + Ok((result, metadata)) } fn put(bytes:&mut [u8],at:usize,w:u32) { bytes[at..at+4].copy_from_slice(&w.to_be_bytes()); } diff --git a/crates/skate-game/src/grind_world/tests.rs b/crates/skate-game/src/grind_world/tests.rs new file mode 100644 index 00000000..ade4b6b6 --- /dev/null +++ b/crates/skate-game/src/grind_world/tests.rs @@ -0,0 +1,149 @@ +use super::*; +use skate_data::skate_map::Rail; + +fn native(id: u64, length: f32) -> Rail { + let mut raw = Vec::new(); + raw.extend_from_slice(&id.to_le_bytes()); + raw.extend_from_slice(&0x2c70_1707_0006_0230u64.to_le_bytes()); + for word in [0u32, 0, 1] { raw.extend_from_slice(&word.to_le_bytes()); } + let mut segment = [0u32; 30]; + segment[0] = (-2.*length).to_bits(); + segment[4] = (3.*length).to_bits(); + segment[15] = 1f32.to_bits(); + segment[24] = length.to_bits(); + for word in segment { raw.extend_from_slice(&word.to_le_bytes()); } + Rail { name: "owned test rail".into(), closed: false, points: vec![], native: Some(raw) } +} + +#[test] +fn no_map_keeps_current_flat_course_empty() { + assert!(primitives(None).unwrap().is_empty()); + let provider = StaticProvider::new(None).unwrap(); + assert!(provider.query([-1.; 3], [1.; 3]).unwrap().is_empty()); +} + +#[test] +fn native_tiny_and_duplicate_knots_are_not_dropped() { + let bytes = spline::build_rails(&[native(1, 0.), native(2, 0.0001)]).unwrap(); + let (entries, metadata) = spline::decoded_from_blob(&bytes).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].start, entries[0].end); + assert_eq!(metadata[0].flags & 0x8000_0000, 0x8000_0000); + assert!(entries[1].end[0] > 0.); + assert_eq!(metadata[1].segment_index, 0); +} + +#[test] +fn same_native_spline_id_does_not_alias_header_instances() { + let bytes = spline::build_rails(&[native(0x1234, 1.), native(0x1234, 2.)]).unwrap(); + let (entries, metadata) = spline::decoded_from_blob(&bytes).unwrap(); + assert_eq!(metadata[0].spline_guids, metadata[1].spline_guids); + assert_eq!(metadata[0].spline_guids, [0x1234, 0x2c70_1707_0006_0230]); + assert_ne!(entries[0].owner, entries[1].owner); +} + +#[test] +fn provider_keeps_both_header_guids_and_source_rail_identity() { + use skate_data::skate_map::{Extension, Geometry, SkateMap}; + // Synthetic numeric records exercise the stock package layout without + // embedding private stock bytes. The second GUID is not an asset ID. + let mut rails = [native(0x1234, 1.), native(0x1234, 2.)]; + let second_guid = 0x1020_3040_5060_7080u64; + rails[1].native.as_mut().unwrap()[8..16].copy_from_slice(&second_guid.to_le_bytes()); + let records: Vec<_> = rails.iter_mut().enumerate().map(|(index, rail)| { + rail.name = format!("0xAA_9_{}", index+7); + serde_json::json!({ + "stream_file": "synthetic.xsf", "asset_id": "0xAA", + "section_index": 9, "section_offset": 128, "rail_index": index+7, + "spline_id": "0x1234", "type_signature": if index == 0 { + "0x2c70170700060230".to_owned() + } else { format!("0x{second_guid:016x}") }, + "segment_count": 1, "flags": 0, "trailing_word": 0, "closed": false + }) + }).collect(); + let map = SkateMap { + version: 14, name: "metadata fixture".into(), spawn: [0.; 3], heading: 0., + environment: vec![], materials: vec![], textures: vec![], + geometry: Geometry { vertices: vec![], indices: vec![], collision: vec![] }, + rails: rails.into(), doors: vec![], lights: vec![], routes: vec![], + extensions: vec![Extension { tag: *b"WMET", schema: 1, + payload: serde_json::to_vec(&serde_json::json!({ + "grind_coordinate_policy": { "mode": "world_space" }, + "grind_splines": records + })).unwrap() }], + }; + let provider = StaticProvider::new(Some(&map)).unwrap(); + let entries = provider.primitives(); + assert_eq!(provider.metadata(1).unwrap().spline_guids, [0x1234, second_guid]); + assert_eq!(provider.spline_guids(entries[1].owner), Some([0x1234, second_guid])); + assert_ne!(provider.spline_guids(entries[0].owner), provider.spline_guids(entries[1].owner)); + assert_eq!(provider.source_rail_index(0), Some(7)); + assert_eq!(provider.source_rail_index(1), Some(8)); + assert_eq!(provider.source(1).unwrap().asset_id, "0xAA"); + assert_eq!(provider.metadata(1).unwrap().flags, 0); + assert_eq!(provider.spline_guids(0), None); + assert_eq!(provider.spline_guids(u64::MAX), None); + assert!(provider.metadata(2).is_none()); +} + +#[test] +fn vertical_flag_uses_horizontal_tolerance_not_chord_length() { + let epsilon = f32::from_bits(0x3780_0000); + let bytes = spline::build_rails(&[native(1, epsilon), native(2, epsilon*2.)]).unwrap(); + let (_, metadata) = spline::decoded_from_blob(&bytes).unwrap(); + assert_eq!(metadata[0].flags, 0x8000_0000); + assert_eq!(metadata[1].flags, 0); +} + +#[test] +fn native_payload_and_inclusive_link_bounds_survive_conversion() { + let rail = native(0x1234_5678_9abc_def0, 1.); + let bytes = spline::build_rails(&[rail]).unwrap(); + let word = |offset| u32::from_be_bytes(bytes[offset..offset+4].try_into().unwrap()); + assert_eq!(word(16), 0x1234_5678); + assert_eq!(word(20), 0x9abc_def0); + assert_eq!(word(36), 48); + assert_eq!(word(40), 48); + assert_eq!(word(48+120), 16); + assert_eq!(word(48+124), 0); + assert_eq!(word(48+128), 0); +} + +#[test] +fn octree_resident_head_insertion_and_forty_cap() { + use super::octree::{Bounds, Octree}; + let b = Bounds { min: [-1.; 3], max: [1.; 3] }; + let tree = Octree::new(b, vec![b; 50]).unwrap(); + assert_eq!(tree.query(b, 40), (10..50).rev().collect::>()); + assert_eq!(tree.query(b, 0), Vec::::new()); +} + +#[test] +fn identity_bounds_transform_retains_native_center_rounding() { + use super::octree::Bounds; + let authored = Bounds { min: [16_777_216.; 3], max: [16_777_218.; 3] }; + let query_bounds = authored.identity_transformed(); + assert_eq!(query_bounds.min, [16_777_215.; 3]); + assert_eq!(query_bounds.max, [16_777_216.; 3]); +} + +#[test] +fn native_split_redistributes_old_linked_list_before_next_insertion() { + use super::octree::{Bounds, Octree}; + let b = Bounds { min: [-1.; 3], max: [1.; 3] }; + let point = Bounds { min: [0.; 3], max: [0.; 3] }; + // Fourth movable entry causes one split; fifth causes a second. Each + // redistribution walks the old head and prepends, reversing that list. + let tree = Octree::new(b, vec![point; 5]).unwrap(); + assert_eq!(tree.query(b, 40), [3, 2, 1, 0, 4]); +} + +#[test] +fn malformed_native_counts_and_inverted_authored_bounds_are_errors() { + let mut rail = native(1, 1.); + rail.native.as_mut().unwrap()[24..28].copy_from_slice(&2u32.to_le_bytes()); + assert!(spline::build_rails(&[rail]).is_err()); + let mut rail = native(1, 1.); + rail.native.as_mut().unwrap()[28+80..28+84].copy_from_slice(&2f32.to_le_bytes()); + assert!(spline::build_rails(&[rail]).is_err()); +} diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs index e41a76db..951bbe9c 100644 --- a/crates/skate-game/src/input.rs +++ b/crates/skate-game/src/input.rs @@ -65,3 +65,6 @@ fn publish_actions( input.publish_actions(); published.0 = input.tick_input(); } + +#[cfg(test)] +pub(crate) mod manual_replay; diff --git a/crates/skate-game/src/input/gesture_catalog.rs b/crates/skate-game/src/input/gesture_catalog.rs index 848b9bde..e77ddc7e 100644 --- a/crates/skate-game/src/input/gesture_catalog.rs +++ b/crates/skate-game/src/input/gesture_catalog.rs @@ -38,10 +38,7 @@ impl Group { Self::NoseN90 => &NOSE_N90, Self::TailN90 => &TAIL_N90, }; - COMMON - .iter() - .chain(additional) - .any(|name| has(name)) + COMMON.iter().chain(additional).any(|name| has(name)) } } diff --git a/crates/skate-game/src/input/manual_replay.rs b/crates/skate-game/src/input/manual_replay.rs new file mode 100644 index 00000000..01701625 --- /dev/null +++ b/crates/skate-game/src/input/manual_replay.rs @@ -0,0 +1,18 @@ +//! Opt-in verification device packets, never a gameplay state/force override. +//! The same packets run in production-frame tests and the normal rendered game. +use skate_core::input::xbox::XboxState; + +pub(crate) const END_TICK: u64 = 330; + +pub(crate) fn sample(tick: u64, sign: i16) -> XboxState { + XboxState { + buttons: if (12..40).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], + left: [0; 2], + right: if (45..195).contains(&tick) { + [0, sign * 22000] + } else { + [0; 2] + }, + } +} diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index f8c958ef..cf0526e2 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -1,3 +1,4 @@ +mod fps_overlay; mod animation; mod crash_report; mod crash_context; @@ -23,7 +24,6 @@ mod difficulty; mod graph_host; mod graph_runtime; mod input; -mod fps_overlay; mod session_marker; mod physics; mod skater_animation; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 1bbfa7b0..981d059d 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -1,15 +1,18 @@ //! Host game physics ownership and schedule. Physical calculations stay in core. -mod animated_skeleton; -mod air_reckoning; +/// Shared stock physics_mode key for loaded settings and per-frame mode packets. +pub(crate) const PHYSICS_MODE: &str = "easy"; + mod air_phase; +mod air_reckoning; mod air_trajectory; +mod animated_skeleton; pub(crate) mod camera_output; mod clock; -mod climbing; mod colliders; mod controls; mod foot_ik; mod footplant; +mod climbing; mod plant_skeleton; mod boneless; mod handplant; @@ -20,10 +23,10 @@ mod render_pose; mod riding_outputs; mod skateboard_controller; mod skater; -mod skeleton_body; mod skeleton_air; -mod skeleton_controller; +mod skeleton_body; mod skeleton_colliders; +mod skeleton_controller; mod skeleton_feedback; mod skeleton_input_runtime; mod skeleton_output; @@ -34,25 +37,37 @@ mod animation_feedback; mod animation_feedback_settings; mod animation_input; mod animation_phase; -mod landing_quality; +mod biped_ground; mod frame; mod grind; -mod ground_phase; +mod grind_air_settings; +mod grind_camera; +mod grind_chromosome; +mod grind_host; +mod grind_materials; +mod grind_names; mod ground_animation; mod slide_state; mod revert_state; mod ground_exit; +mod ground_phase; mod ground_runtime; mod input_phase; +mod landing_quality; +mod offboard; mod player_input; mod player_state; mod settings; +mod skeleton_grind_air; +mod teleport_state; mod wipeout; mod wipeout_states; -mod teleport_state; mod respawn; -mod offboard; +//TEMPORARY opt-in observations for the bottom-up source audit. +mod biped_air; mod known_air; +mod landing_on_deck; +mod offboard_audit_trace; use crate::{ app::{FrameSet, SimulationSet}, world::PlayerRoot, @@ -61,10 +76,11 @@ use bevy::prelude::*; pub(crate) use controls::PlayerControls; use riding_outputs::RidingOutputs; use settings::PhysicsSettings; +#[cfg(test)] +use skate_core::physics::board::BodyId; use skate_core::{ math::Vector3, physics::{ - board::BodyId, board_runtime::{BoardMotion, BoardRuntime}, board_world::{BoardWorld, ContactRetentionSettings}, collision::WorldContactSettings, @@ -81,8 +97,10 @@ pub(crate) struct GamePhysics { clock: clock::SimulationClock, pub board: BoardRuntime, pub riding: RidingOutputs, - grind: grind::Runtime, world: BoardWorld, + grind_world: std::sync::Arc, + grind_materials: grind_materials::GrindMaterials, + offboard_grab_scene: offboard::grab_scene::Registry, settings: PhysicsSettings, animation_profile: animation_phase::AnimationProfile, query: WorldContactSettings, @@ -154,23 +172,33 @@ mod exchange_tests { #[test] fn exchange_keeps_events_on_the_authoritative_tick() { let mut exchange = SimulationExchange::new(8); - assert!(exchange.emit_event( - 7, - skate_core::physics::phase::PhysicsEvent::StateChanged { - from: skate_core::player::state::PhysicalStateId::PhysicsGround, - to: skate_core::player::state::PhysicalStateId::PhysicsAir, - }, - ).is_err()); - assert!(exchange.emit_event( - 8, - skate_core::physics::phase::PhysicsEvent::StateChanged { - from: skate_core::player::state::PhysicalStateId::PhysicsGround, - to: skate_core::player::state::PhysicalStateId::PhysicsAir, - }, - ).is_ok()); - assert!(exchange - .request_state(skate_core::player::state::PhysicalStateId::PhysicsAir) - .is_ok()); + assert!( + exchange + .emit_event( + 7, + skate_core::physics::phase::PhysicsEvent::StateChanged { + from: skate_core::player::state::PhysicalStateId::PhysicsGround, + to: skate_core::player::state::PhysicalStateId::PhysicsAir, + }, + ) + .is_err() + ); + assert!( + exchange + .emit_event( + 8, + skate_core::physics::phase::PhysicsEvent::StateChanged { + from: skate_core::player::state::PhysicalStateId::PhysicsGround, + to: skate_core::player::state::PhysicalStateId::PhysicsAir, + }, + ) + .is_ok() + ); + assert!( + exchange + .request_state(skate_core::player::state::PhysicalStateId::PhysicsAir) + .is_ok() + ); assert_eq!(exchange.commands.tick(), 8); assert_eq!(exchange.commands.commands().len(), 1); assert_eq!(exchange.events().len(), 1); @@ -192,21 +220,41 @@ impl GamePhysics { // Keep the board, active trick, equipment preferences and controller history. self.animation_profile.physics_mode = difficulty as u32; } + pub(crate) fn simulation_elapsed_us(&self) -> u64 { + (self.ticks as f64 * f64::from(self.settings.step.simulation.time_step) * 1_000_000.0) + as u64 + } pub(crate) fn period(&self) -> std::time::Duration { self.clock.period() } pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode } + #[cfg(test)] + pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { self.world.triangles() } + pub(crate) fn world(&self) -> &BoardWorld { &self.world } - pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { - self.world.triangles() + /// Flat-world convenience used by private-asset integration tests. + #[cfg(test)] + pub fn load(asset_root: &std::path::Path) -> Result { + Self::load_with_terrain(asset_root, ground::Terrain::Flat) } - pub fn load(asset_root: &std::path::Path) -> Result { - Self::load_with_map(asset_root, None) + pub(crate) fn load_with_terrain( + asset_root: &std::path::Path, + terrain: ground::Terrain, + ) -> Result { + Self::load_with_world(asset_root, terrain, None) + } + + pub(crate) fn load_with_world( + asset_root: &std::path::Path, + terrain: ground::Terrain, + map: Option<&skate_data::skate_map::SkateMap>, + ) -> Result { + Self::load_world_difficulty(asset_root, terrain, map, crate::difficulty::Difficulty::Easy) } pub fn load_with_map(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>) -> Result { @@ -214,9 +262,17 @@ impl GamePhysics { } pub fn load_with_difficulty(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>, difficulty: crate::difficulty::Difficulty) -> Result { + Self::load_world_difficulty(asset_root, ground::Terrain::Course, map, difficulty) + } + + fn load_world_difficulty(asset_root: &std::path::Path, terrain: ground::Terrain, map: Option<&skate_data::skate_map::SkateMap>, difficulty: crate::difficulty::Difficulty) -> Result { let data = Collections::load(asset_root)?; let settings = PhysicsSettings::load(&data)?; let animation_profile = animation_phase::AnimationProfile::load(&data, difficulty.key())?; + eprintln!( + "SKATE_PHYSICS_MODE {PHYSICS_MODE} index={}", + animation_profile.physics_mode + ); let mut spawn = RetailAffineTransform { translation: Vector3::new( 0.0, @@ -226,10 +282,15 @@ impl GamePhysics { ..RetailAffineTransform::IDENTITY }; if let Some(map) = map { - // Native collision placement aligns the package spawn with the - // wheel-ground point (skate3_native_collision.cpp), not skeleton COM. - spawn.translation = Vector3::new(map.spawn[0], map.spawn[1] + settings.wheel_radius - settings.authored[0].translation.y, map.spawn[2]); - spawn.basis = skate_core::math::Basis3 { columns: Mat3::from_rotation_y(map.heading).to_cols_array_2d() }; + // Package spawn is the wheel-ground anchor, in native Y-up metres. + spawn.translation = Vector3::new( + map.spawn[0], + map.spawn[1] + settings.wheel_radius - settings.authored[0].translation.y, + map.spawn[2], + ); + spawn.basis = skate_core::math::Basis3 { + columns: Mat3::from_rotation_y(map.heading).to_cols_array_2d(), + }; } let board = BoardRuntime::new( settings.masses, @@ -240,8 +301,24 @@ impl GamePhysics { ); let world = match map { Some(map) => crate::skate_world::collision_world(map, settings.floor_material)?, - None => ground::world(settings.floor_material), + None => terrain.world(settings.floor_material), }; + let grind_world = std::sync::Arc::new(if map.is_none() && terrain == ground::Terrain::Course { + crate::grind_world::StaticProvider::authored(&crate::grind_world::test_rails())? + } else { crate::grind_world::StaticProvider::new(map)? }); + if let Some(map) = map { + eprintln!( + "SKATE_GRIND_READY splines={} primitives={}", + map.rails.len(), + grind_world.primitives().len() + ); + } + let grind_materials = grind_materials::GrindMaterials::new(&settings); + //Both authored terrains contain static collision surfaces only, with + //no interactable objects or assembly-bound grab splines. Do not infer + //those identities from triangle/mesh IDs. Queries use the real registry. + let offboard_grab_scene = + offboard::grab_scene::Registry::new(&world, Vec::new(), Vec::new())?; let processed_flags_2468 = 0x2000; let riding = RidingOutputs::load(&data, &board, processed_flags_2468)?; let (query, retention) = ground::query_settings(); @@ -250,10 +327,12 @@ impl GamePhysics { network_active: false, network_contacts: 0, clock: clock::SimulationClock::default(), - grind: grind::Runtime::load(&data, map)?, board, riding, world, + grind_world, + grind_materials, + offboard_grab_scene, settings, animation_profile, query, @@ -301,12 +380,23 @@ impl GamePhysics { } } +#[cfg(test)] +#[path = "tests/map_startup.rs"] +mod map_startup; + pub(crate) struct PhysicsPlugin; impl Plugin for PhysicsPlugin { fn build(&self, app: &mut App) { let period = app.world().resource::().clock.period(); + let asset_root = app + .world() + .resource::() + .asset_root + .clone(); + let controls = PlayerControls::load(&asset_root) + .unwrap_or_else(|error| panic!("Cannot initialize trick gesture recognizers: {error}")); app.insert_resource(Time::::from_duration(period)) - .init_resource::() + .insert_resource(controls) .add_systems( FixedUpdate, controls::sample.in_set(SimulationSet::Controls), @@ -382,16 +472,14 @@ impl GamePhysics { self.processed_flags_2468, self.settings.step.simulation.time_step, )?; - offboard::board_effects::classify_alignment( - &skater.offboard.board_policy, - &mut self.riding.ground, - ); let partial = skateboard_controller::partial_request( &skater.skateboard_controller, &self.riding.ground, ); skeleton_feedback::publish(self, skater, partial); - if skater.player_state.current() == skate_core::player::state::PhysicalStateId::WipeoutGround { + if skater.player_state.current() + == skate_core::player::state::PhysicalStateId::WipeoutGround + { wipeout_states::post_physics(skater); } if skater.player_state.current() == skate_core::player::state::PhysicalStateId::PhysicsAir { @@ -400,23 +488,42 @@ impl GamePhysics { if skater.player_state.current() == skate_core::player::state::PhysicalStateId::KnownAir { known_air::post_physics(self, skater)?; } + if skater.player_state.current().is_grind() + || skater.player_state.current() + == skate_core::player::state::PhysicalStateId::Nonspecific + { + grind::post(self, skater)?; + } wipeout::check_after_physics(self, skater)?; + offboard::post_physics::advance(self, skater)?; let compression = skater.skeleton_output.average_compressions(&self.board); render_pose::publish(self, skater, compression)?; self.ticks += 1; - let invalid = self - .board + let invalid_board = self.board.bodies().iter().enumerate().find(|(_, body)| { + let p = body.rates.position; + let v = body.rates.linear_velocity; + let w = body.rates.angular_velocity; + [p.x, p.y, p.z, v.x, v.y, v.z, w.x, w.y, w.z] + .into_iter() + .any(|value| !value.is_finite()) + }); + let invalid_skeleton = skater + .skeleton .bodies() .iter() - .chain(skater.skeleton.bodies()) - .any(|body| { - let position = body.rates.position; - !position.x.is_finite() || !position.y.is_finite() || !position.z.is_finite() + .enumerate() + .find(|(_, body)| { + let p = body.rates.position; + let v = body.rates.linear_velocity; + let w = body.rates.angular_velocity; + [p.x, p.y, p.z, v.x, v.y, v.z, w.x, w.y, w.z] + .into_iter() + .any(|value| !value.is_finite()) }); - if invalid { + if invalid_board.is_some() || invalid_skeleton.is_some() { self.failed = true; return Err(format!( - "Shared skater solver produced a non-finite pose on tick{}", + "Shared skater solver produced non-finite rates on tick{}; board={invalid_board:?}; skeleton={invalid_skeleton:?}", self.ticks )); } @@ -436,14 +543,6 @@ mod air_tests; #[path = "tests/wipeout_playback.rs"] mod wipeout_tests; -#[cfg(test)] -#[path = "tests/difficulty.rs"] -mod difficulty_tests; - -#[cfg(test)] -#[path = "tests/trick_turn.rs"] -mod trick_turn_tests; - fn present( physics: Res, history: Res, @@ -464,3 +563,35 @@ fn present( #[cfg(test)] #[path = "tests/powerslide_playback.rs"] mod powerslide_tests; + +#[cfg(test)] +#[path = "tests/manual_playback.rs"] +mod manual_tests; + +#[cfg(test)] +#[path = "tests/hippy_playback.rs"] +mod hippy_tests; + +#[cfg(test)] +#[path = "tests/recorded_playback.rs"] +mod recorded_tests; + +#[cfg(test)] +#[path = "tests/offboard_air_playback.rs"] +mod offboard_air_tests; + +#[cfg(test)] +#[path = "tests/offboard_midair_playback.rs"] +mod offboard_midair_playback; + +#[cfg(test)] +#[path = "tests/offboard_recall_playback.rs"] +mod offboard_recall_playback_tests; + +#[cfg(test)] +#[path = "tests/offboard_jump_playback.rs"] +mod offboard_jump_playback_tests; + +#[cfg(test)] +#[path = "tests/offboard_root_trace.rs"] +mod offboard_root_trace; diff --git a/crates/skate-game/src/physics/air_phase.rs b/crates/skate-game/src/physics/air_phase.rs index d423ead5..34cfcb22 100644 --- a/crates/skate-game/src/physics/air_phase.rs +++ b/crates/skate-game/src/physics/air_phase.rs @@ -1,16 +1,15 @@ //! PhysicsAir200 production lifecycle82D34388/82D346C0/82D346A0. //! Uses the live player, board, skeleton and selector; no alternate air solver. -mod input; mod board; +mod input; mod skeleton; -pub(crate) use input::AirSettings; -pub(crate) use input::selector_input; -pub(crate) use input::launch_info; use super::{GamePhysics, SkaterRuntime}; +pub(crate) use input::AirSettings; +pub(crate) use input::launch_info; +pub(crate) use input::selector_input; use skate_core::air::state::{ - AirMath, AirTrajectory, PhysicsAirState, PhysicsAirMath, - angle_between_vectors, calculate_velocity_from_jump, integrate_trajectory_fixed_step, - wrap_signed_angle, + AirMath, AirTrajectory, PhysicsAirMath, PhysicsAirState, angle_between_vectors, + calculate_velocity_from_jump, integrate_trajectory_fixed_step, wrap_signed_angle, }; ///Original initializer82F826B0/822F8B40. This COM trajectory acceleration is @@ -21,10 +20,16 @@ pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re let frame = input::frame(skater); let life = &mut skater.ground_lifecycle; life.skeleton_controller.flag_18 = true; - life.skeleton_controller.request(7, &mut skater.skeleton_collision)?; + life.skeleton_controller + .request(7, &mut skater.skeleton_collision)?; skater.foot_ik.enable_feet(true); - life.skeleton_controller.request(6, &mut skater.skeleton_collision)?; - physics.board.hook_mut().drive.enable_angular_only(&mut life.board_animated_290); + life.skeleton_controller + .request(6, &mut skater.skeleton_collision)?; + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut life.board_animated_290); skater.footplant.enabled = false; skater.footplant.reset(); let state = &mut skater.air_state; @@ -36,7 +41,8 @@ pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re state.max_y = physics.board.part_transforms()[6].translation.y; state.start_y = frame.ground_position_y_500; let (use_com, heading) = if frame.previous_physics_category_2516 == 400 - || frame.previous_physics_state_2504 == 701 { + || frame.previous_physics_state_2504 == 701 + { (frame.flags_2468 & 0x4000 == 0, false) } else { let selected = !matches!(frame.previous_physics_state_2504, 100 | 103 | 201); @@ -49,14 +55,22 @@ pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re velocity[1] = AirMath.minimum_vminfp(velocity[1] * f32::from_bits(0x3f26_6666), 3.0); } if frame.frames_since_jump_correction_2576 < 12 { - velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + velocity = calculate_velocity_from_jump( + &frame, + frame.frames_since_jump_correction_2576, + &mut AirMath, + ); } state.centre_of_mass_trajectory = AirTrajectory { - position: frame.trajectory_position_592, velocity, - acceleration: COM_ACCELERATION, scalar_48: -1.0, + position: frame.trajectory_position_592, + velocity, + acceleration: COM_ACCELERATION, + scalar_48: -1.0, }; } - if heading { skater.animated_skeleton.roots.initialize_heading = true; } + if heading { + skater.animated_skeleton.roots.initialize_heading = true; + } Ok(()) } @@ -69,20 +83,30 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> let frame = input::frame(skater); skater.ground_lifecycle.skeleton_controller.update_air( skater.player_input.processed.flags_2472, - frame.current_velocity_400[1], &mut skater.skeleton_collision, + frame.current_velocity_400[1], + &mut skater.skeleton_collision, )?; let state = &mut skater.air_state; state.trajectory_query_countdown = state.trajectory_query_countdown.wrapping_sub(1); - if state.trajectory_query_countdown <= 0 && frame.flags_2468 & 0x8000 == 0 - && frame.previous_physics_state_2504 != 0 && !skater.trajectory.selector.pending() { + if state.trajectory_query_countdown <= 0 + && frame.flags_2468 & 0x8000 == 0 + && frame.previous_physics_state_2504 != 0 + && !skater.trajectory.selector.pending() + { let mut info = input::launch_info(physics, skater)?; if frame.frames_since_jump_correction_2576 < 12 { - info.start_velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + info.start_velocity = calculate_velocity_from_jump( + &frame, + frame.frames_since_jump_correction_2576, + &mut AirMath, + ); } else if skater.air_state.use_centre_of_mass_velocity { //COM helper82D34570 repeats Fill, then offsets start/board and //narrows the cone. Caller clears player_jumped again before Launch. let velocity = skater.air_state.centre_of_mass_trajectory.velocity; - info.start_velocity = std::array::from_fn(|i| COM_ACCELERATION[i].mul_add(f32::from_bits(0x3c88_8889), velocity[i])); + info.start_velocity = std::array::from_fn(|i| { + COM_ACCELERATION[i].mul_add(f32::from_bits(0x3c88_8889), velocity[i]) + }); info.start_position_override = info.animation_com_position; info.start_position_override[1] += f32::from_bits(0xbf26_6666); info.board_position_override = info.animation_com_position; @@ -98,15 +122,37 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> skater.air_state.trajectory_query_countdown = 7; } let input = input::selector_input(physics, skater)?; - skater.trajectory.update(input, &physics.world)?; - let current = skater.air_reckoning.fields(&physics.riding).current_landing_normal_1152; - let normal = skater.trajectory.selector.suggested_normal().unwrap_or(current); + let grind_context = super::air_trajectory::GrindContext::from_processed( + &skater.player_input.processed, + skater + .player_input + .toolkit + .as_ref() + .ok_or("Air trajectory requires current board toolkit")? + .deck[3], + ); + skater + .trajectory + .update(input, &physics.world, grind_context)?; + let current = skater + .air_reckoning + .fields(&physics.riding) + .current_landing_normal_1152; + let normal = skater + .trajectory + .selector + .suggested_normal() + .unwrap_or(current); let state = &mut skater.air_state; state.landing_normal = normal; let settings = &skater.air_settings.state; - let mut blend = if wrap_signed_angle(angle_between_vectors(normal, current)) <= settings.landing_normal_angle_limit_444 { + let mut blend = if wrap_signed_angle(angle_between_vectors(normal, current)) + <= settings.landing_normal_angle_limit_444 + { settings.landing_normal_blend_388 - } else { 0.0 }; + } else { + 0.0 + }; if !state.use_centre_of_mass_velocity { if !state.selector_latch_174 { state.selector_latch_174 = skater.trajectory.selector.all_predictions_missed(); @@ -114,28 +160,50 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> if state.selector_latch_174 { state.use_centre_of_mass_velocity = true; state.centre_of_mass_trajectory = AirTrajectory { - position: frame.trajectory_position_592, velocity: frame.trajectory_velocity_608, - acceleration: COM_ACCELERATION, scalar_48: -1.0, + position: frame.trajectory_position_592, + velocity: frame.trajectory_velocity_608, + acceleration: COM_ACCELERATION, + scalar_48: -1.0, }; } } - if frame.state_timer_2664 <= 0.0 { blend = 0.0; } - else if frame.flags_2468 & 0x4000 != 0 { state.use_centre_of_mass_velocity = false; } - let spin = (frame.body_spin_input_2640 * settings.body_spin_scale_428) * f32::from_bits(0x3c8e_fa35); - let target_spin = settings.body_spin_over_time_320.evaluate(state.time_in_state * 2.0) * spin; + if frame.state_timer_2664 <= 0.0 { + blend = 0.0; + } else if frame.flags_2468 & 0x4000 != 0 { + state.use_centre_of_mass_velocity = false; + } + let spin = + (frame.body_spin_input_2640 * settings.body_spin_scale_428) * f32::from_bits(0x3c8e_fa35); + let target_spin = settings + .body_spin_over_time_320 + .evaluate(state.time_in_state * 2.0) + * spin; skater.air_reckoning.update( - &mut physics.riding, &skater.player_input.processed, - skater.animation_input.extra.physical_body_spin, normal, blend, target_spin, 0.0, + &mut physics.riding, + &skater.player_input.processed, + skater.animation_input.extra.physical_body_spin, + normal, + blend, + target_spin, + 0.0, )?; if skater.air_state.use_centre_of_mass_velocity { integrate_trajectory_fixed_step(&mut skater.air_state.centre_of_mass_trajectory); - skeleton::advance(physics, skater, Some(skater.air_state.centre_of_mass_trajectory.position))?; - } else { skeleton::advance(physics, skater, None)?; } + skeleton::advance( + physics, + skater, + Some(skater.air_state.centre_of_mass_trajectory.position), + )?; + } else { + skeleton::advance(physics, skater, None)?; + } board::advance(physics, skater, frame)?; - skater.ground_lifecycle.skeleton_ground_16388 = true; + skater.skeleton_output.correction.pending = true; let height = physics.board.part_transforms()[6].translation.y; let state = &mut skater.air_state; - if !(state.max_y > height) { state.max_y = height; } + if !(state.max_y > height) { + state.max_y = height; + } state.time_in_state += frame.delta_time_2604; Ok(()) } diff --git a/crates/skate-game/src/physics/air_phase/board.rs b/crates/skate-game/src/physics/air_phase/board.rs index c5d9dc36..b80ce17b 100644 --- a/crates/skate-game/src/physics/air_phase/board.rs +++ b/crates/skate-game/src/physics/air_phase/board.rs @@ -1,23 +1,58 @@ //!82D34AB0 consumes the completed Air reckoning's collision normal1216. use super::*; -use skate_core::{air::state::PhysicsAirFrame, math::Vector3, - physics::force_queue::QueuedPointForce, riding::collision_response::CollisionResponsePhysical}; -pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, frame: PhysicsAirFrame) -> Result<(), String> { +use skate_core::{ + air::state::PhysicsAirFrame, math::Vector3, physics::force_queue::QueuedPointForce, + riding::collision_response::CollisionResponsePhysical, +}; +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + frame: PhysicsAirFrame, +) -> Result<(), String> { let p = &skater.player_input.processed; - skater.ground.steering.update(0.0, skater.air_settings.steering_blend, p.flags_2468,p.flags_2472); - let t = skater.player_input.toolkit.as_ref().ok_or("Air collision requires the actual BoardToolkit")?; + skater.ground.steering.update( + 0.0, + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("Air collision requires the actual BoardToolkit")?; let normal = physics.riding.reckoning.ground_normal; - if let Some(force) = skater.ground_runtime.calculate_collision_force(CollisionResponsePhysical { - flags_2472: p.flags_2472, collision_displacement: p.collision_pose_error_736.map(f32::from_bits), - velocity: p.vectors_400_416[1].map(f32::from_bits), forward: t.travel_direction, - up: p.vectors_544_560_592_608[0].map(f32::from_bits), ground_normal: [normal.x,normal.y,normal.z,0.0], - time_step: p.timestep_2604, mass: t.total_mass, - }) { - let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); - physics.board.forces_mut().append(QueuedPointForce {tag:15,force_world:xyz(force.force_2528),point_body:xyz(force.point_2544)}); - } else if !skater.air_state.use_centre_of_mass_velocity && frame.frames_since_jump_correction_2576 < 12 { - let velocity = calculate_velocity_from_jump(&frame,frame.frames_since_jump_correction_2576,&mut AirMath); - skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + if let Some(force) = + skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), + forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + ground_normal: [normal.x, normal.y, normal.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }) + { + let xyz = |v: [f32; 4]| Vector3::new(v[0], v[1], v[2]); + physics.board.forces_mut().append(QueuedPointForce { + tag: 15, + force_world: xyz(force.force_2528), + point_body: xyz(force.point_2544), + }); + } else if !skater.air_state.use_centre_of_mass_velocity + && frame.frames_since_jump_correction_2576 < 12 + { + let velocity = calculate_velocity_from_jump( + &frame, + frame.frames_since_jump_correction_2576, + &mut AirMath, + ); + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); } Ok(()) } diff --git a/crates/skate-game/src/physics/air_phase/input.rs b/crates/skate-game/src/physics/air_phase/input.rs index 1e352802..e356884e 100644 --- a/crates/skate-game/src/physics/air_phase/input.rs +++ b/crates/skate-game/src/physics/air_phase/input.rs @@ -1,6 +1,12 @@ //! Actual Processed/animation/stock owners read by PhysicsAir and its launch. use super::*; -use skate_core::{air::{state::{PhysicsAirFrame, PhysicsAirSettings}, trajectory::{LaunchInfo,SelectorInput}},point_graph::PointGraph}; +use skate_core::{ + air::{ + state::{PhysicsAirFrame, PhysicsAirSettings}, + trajectory::{LaunchInfo, SelectorInput}, + }, + point_graph::PointGraph, +}; use skate_data::collections::Collections; pub(crate) struct AirSettings { @@ -10,15 +16,23 @@ pub(crate) struct AirSettings { } impl AirSettings { pub fn load(data: &Collections) -> Result { - let values = data.words::<16>("physics_airstates", "default", "BodySpinInputFilter")?.map(f32::from_bits); + let values = data + .words::<16>("physics_airstates", "default", "BodySpinInputFilter")? + .map(f32::from_bits); let f = |name| data.float("physics_airstates", "default", name); let mut distances = [0.0; 5]; - for (i, mode) in ["easy", "normal", "hardcore", "motorized", "test"].into_iter().enumerate() { + for (i, mode) in ["easy", "normal", "hardcore", "motorized", "test"] + .into_iter() + .enumerate() + { distances[i] = data.float("physics_mode", mode, "GrindLockDist")?; } Ok(Self { state: PhysicsAirSettings { - body_spin_over_time_320: PointGraph {x: values[..8].try_into().unwrap(), y: values[8..].try_into().unwrap()}, + body_spin_over_time_320: PointGraph { + x: values[..8].try_into().unwrap(), + y: values[8..].try_into().unwrap(), + }, landing_normal_blend_388: f("SpeedToAlignToGround_PhysAir")?, body_spin_scale_428: f("MaxSpinSpeed")?, landing_normal_angle_limit_444: f("DontAlignAnglePhysicsAir")?, @@ -36,33 +50,56 @@ pub(super) fn frame(skater: &SkaterRuntime) -> PhysicsAirFrame { trajectory_position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), trajectory_velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), jump_velocity_848: skater.player_state.post.jump_reference.map(f32::from_bits), - flags_2468: p.flags_2468, previous_physics_state_2504: p.state_2504 as i32, + flags_2468: p.flags_2468, + previous_physics_state_2504: p.state_2504 as i32, previous_physics_category_2516: p.category_2516 as i32, frames_since_jump_correction_2576: skater.player_state.post.jump_fix_frames as i32, - delta_time_2604: p.timestep_2604, body_spin_input_2640: skater.animation_input.fields.body_spin, - gravity_y_2648: p.gravity_2648, state_timer_2664: p.state_timer_2664, + delta_time_2604: p.timestep_2604, + body_spin_input_2640: skater.animation_input.fields.body_spin, + gravity_y_2648: p.gravity_2648, + state_timer_2664: p.state_timer_2664, } } -pub(crate) fn selector_input(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { +pub(crate) fn selector_input( + physics: &GamePhysics, + skater: &SkaterRuntime, +) -> Result { let p = &skater.player_input.processed; let g = physics.settings.step.simulation.gravity_acceleration; Ok(SelectorInput { - gravity: [g.x,g.y,g.z,0.0], + gravity: [g.x, g.y, g.z, 0.0], ground_normal: p.vectors_464_480_496_512_528[0].map(f32::from_bits), contact_position: p.vectors_464_480_496_512_528[2].map(f32::from_bits), heading_direction: p.vectors_464_480_496_512_528[3].map(f32::from_bits), reference_up: p.vectors_544_560_592_608[0].map(f32::from_bits), board_vertical_velocity: f32::from_bits(p.vectors_400_416[0][1]), - directional_input: p.transition_2636, previous_physics_state: p.state_2504, - flags_2472: p.flags_2472, flags_2476: p.flags_2476, + directional_input: p.transition_2636, + previous_physics_state: p.state_2504, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, offboard_flags_1776: p.external_physics_1616.flags, - grind_lock_distance: *skater.air_settings.grind_lock_distance.get(p.state_variant_index_2528 as usize) - .ok_or_else(|| format!("Invalid trajectory physics mode {}",p.state_variant_index_2528))?, + grind_lock_distance: *skater + .air_settings + .grind_lock_distance + .get(p.state_variant_index_2528 as usize) + .ok_or_else(|| { + format!( + "Invalid trajectory physics mode {}", + p.state_variant_index_2528 + ) + })?, }) } -pub(crate) fn launch_info(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { +pub(crate) fn launch_info( + physics: &GamePhysics, + skater: &SkaterRuntime, +) -> Result { let p = &skater.player_input.processed; - let toolkit = skater.player_input.toolkit.as_ref().ok_or("Air launch requires current BoardToolkit")?; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Air launch requires current BoardToolkit")?; Ok(LaunchInfo { reckoning_transform: physics.riding.reckoning_frames.system, reckoning_inverse: physics.riding.reckoning_frames.inverse_system, @@ -70,9 +107,12 @@ pub(crate) fn launch_info(physics: &GamePhysics, skater: &SkaterRuntime) -> Resu com_velocity: p.prepared_jump_704.map(f32::from_bits), skeleton_vector_160: skater.animated_skeleton.board_frames.local_centre_of_mass, skeleton_vector_176: skater.animated_skeleton.board_frames.local_board_position, - board_position: toolkit.deck[3], animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), - cone_angle_x: skater.trajectory.settings.cone_x, cone_angle_z: skater.trajectory.settings.cone_z, - timestep: p.timestep_2604, trajectory_count: if p.flags_2468 & 0x2000 != 0 {7} else {1}, + board_position: toolkit.deck[3], + animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + cone_angle_x: skater.trajectory.settings.cone_x, + cone_angle_z: skater.trajectory.settings.cone_z, + timestep: p.timestep_2604, + trajectory_count: if p.flags_2468 & 0x2000 != 0 { 7 } else { 1 }, ..LaunchInfo::default() }) } diff --git a/crates/skate-game/src/physics/air_phase/skeleton.rs b/crates/skate-game/src/physics/air_phase/skeleton.rs index a36a5670..7a523240 100644 --- a/crates/skate-game/src/physics/air_phase/skeleton.rs +++ b/crates/skate-game/src/physics/air_phase/skeleton.rs @@ -1,24 +1,46 @@ //! Same live Skeleton owners as Ground; Air chooses its original update method. use super::*; use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; -pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, com: Option<[f32;4]>) -> Result<(), String> { +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + com: Option<[f32; 4]>, +) -> Result<(), String> { let collision = input_phase::collision(skater); let mut owners = SkeletonOwners { - animated: &mut skater.animated_skeleton, body: &mut skater.skeleton, - drives: &mut skater.skeleton_drives, ik: &mut skater.foot_ik, - animation_input: &mut skater.animation_input, correction: &mut skater.skeleton_output.correction, + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, pose_errors: &mut skater.pose_errors, }; let packet = &skater.animation.packet; if let Some(com) = com { skater.skeleton_input.update_known_air( - &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system,com,packet, - &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation, + &mut skater.skeleton_air, + &mut physics.board, + &physics.riding.reckoning_frames.system, + com, + packet, + &mut skater.player_input.processed, + &mut owners, + &packet.hierarchy, + &collision, + physics.settings.step.simulation, )?; } else { skater.skeleton_input.update_animated( - &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system, - &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation,false, + &mut skater.skeleton_air, + &mut physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &packet.hierarchy, + &collision, + physics.settings.step.simulation, + false, )?; } Ok(()) diff --git a/crates/skate-game/src/physics/air_trajectory/grind.rs b/crates/skate-game/src/physics/air_trajectory/grind.rs index 85231c4f..50885c37 100644 --- a/crates/skate-game/src/physics/air_trajectory/grind.rs +++ b/crates/skate-game/src/physics/air_trajectory/grind.rs @@ -1,13 +1,34 @@ -//! Live TU3 trajectory-to-spline query82D69C00 and admission82D6A840. +//! Real static University provider -> native trajectory admission -> landing orientation. +use crate::grind_world::StaticProvider; use skate_core::{ - air::trajectory::{grind::*, Prediction}, - physics::{board_world::BoardWorld, grind_contact::Primitive}, + air::trajectory::{ + Prediction, + grind::*, + grind_surface::{self, GeometryType, InvestigationInput, LandingOrientation}, + }, + physics::board_world::BoardWorld, + player::input_phase::ProcessedPhysicsInput, point_graph::PointGraph, }; use skate_data::collections::Collections; type V = [f32; 4]; +#[derive(Clone, Copy, Debug)] +pub(crate) struct GrindContext { + pub board_position: V, + pub body_position: V, + pub actor: [u32; 2], +} +impl GrindContext { + pub fn from_processed(p: &ProcessedPhysicsInput, board_position: V) -> Self { + Self { + board_position, + body_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + actor: [p.actor_query_2948, p.actor_query_2952], + } + } +} pub(super) struct Settings { - pub limits: GrindAssistLimits, + limits: GrindAssistLimits, height: PointGraph<8>, padding: f32, maximum_adjust: f32, @@ -15,13 +36,14 @@ pub(super) struct Settings { max_angle: f32, score: f32, truck_distance: f32, + penalty_domain: 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, + lock_distance: 0., max_speed_squared_ledge: t("GrindMaxSpeedSqrIntoLedge")?, max_speed_squared_rail: t("GrindMaxSpeedSqrIntoGrind")?, max_downward_speed: t("GrindMaxSpeedDownOntoGrind")?, @@ -38,18 +60,16 @@ impl Settings { data.float("physicsdeck", "default", "DeckFrontEndSize")?, ], }, - height: super::settings::graph8( - data, - "physics_trajectory", - "RequiredAngleVsHeight", - true, - )?, + height: 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")?, + penalty_domain: f32::from_bits( + data.words::<20>("physics_trajectory", "default", "GrindPenaltyVsDistToGrind")?[2], + ), }) } pub fn evaluate( @@ -57,109 +77,206 @@ impl Settings { prediction: &mut Prediction, acquire: bool, world: &BoardWorld, - edges: &[Primitive], - nearby: &mut Vec, + provider: &StaticProvider, + nearby: &mut Vec, lock_distance: f32, - com: V, - board_position: V, + context: GrindContext, ) -> Result { - let velocity = prediction.collision_velocity(); - let landing = prediction - .request - .trajectory - .position_at(prediction.result.contact_time); + let collision_position = prediction.collision_position(); + let collision_velocity = prediction.collision_velocity(); 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 min = + core::array::from_fn(|i| collision_position[i] - if i == 1 { 0.5 } else { 2. }); + let max = + core::array::from_fn(|i| collision_position[i] + if i == 1 { 4. } else { 2. }); + let indices = provider.query(min, max)?; + *nearby = + trajectory_box_filter(&indices, provider.primitives(), context.board_position); } - 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 { + let mut result = GrindEvaluation { target: None, - score: 0.0, - distance, - }) + score: 0., + distance: 1000., + effective_lock_distance: None, + penalty_domain: self.penalty_domain, + }; + //82D69D68: retain the last real query for non-acquisition candidates. + let mut square = 1_000_000.; + for &index in nearby.iter() { + let edge = provider.primitives()[index]; + let axis = unit(sub(edge.end, edge.start)); + let from = sub(collision_position, edge.start); + let end = sub(collision_position, edge.end); + let perpendicular = sub(from, scale(axis, dot(axis, from))); + square = dot(perpendicular, perpendicular) + .min(dot(from, from)) + .min(dot(end, end)) + .min(square); + } + if !nearby.is_empty() { + result.distance = length_square(square); + } + if !acquire || nearby.is_empty() { + return Ok(result); + } + let mut candidates = nearby + .iter() + .filter_map(|&i| { + consider_grind_primitive(*prediction, provider.primitives()[i], 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 edge = provider.primitives()[c.primitive]; + let query = InvestigationInput { + start: edge.start, + end: edge.end, + reference: c.point, + optional_probe: None, + deck_center_to_truck: self.truck_distance, + }; + //No submission is a real failed candidate, not an INVALID completed fallback. + if grind_surface::prepare(query).is_none() { + continue; + } + let surface = grind_surface::investigate(query, |index, probe| { + crate::physics::player_input::grind::world::surface_probe( + world, + context.actor, + index, + probe, + ) + })?; + //All fields below are written by the completed producer before consumption. + let mut orientation = LandingOrientation { + kind: GeometryType::Impossible, + garbage: false, + boardslide_dir: [0.; 4], + tipslide_dir: [0.; 4], + backslash_dir: [0.; 4], + high_side: [0.; 4], + }; + let mut support = [0.; 4]; + grind_surface::update_landing_orientation( + Some(&surface), + context.board_position, + c.point, + &mut support, + &mut orientation, + ); + let Some(delta) = admitted_displacement( + *prediction, + c, + edge, + collision_velocity, + context.body_position, + GrindSurfaceEvidence { + kind: orientation.kind as u32, + side: orientation.high_side, + }, + &limits, + &mut result.effective_lock_distance, + ) else { + continue; + }; + let original_velocity = prediction.request.trajectory.velocity; + apply_admitted_target( + prediction, + c, + delta, + support, + original_velocity, + self.maximum_adjust, + self.velocity_scalar, + self.max_angle, + ); + let vertical = cross(cross(c.direction, [0., 1., 0., 0.]), c.direction); + let vertical = if vertical[1] < 0. { + scale(vertical, -1.) + } else { + vertical + }; + let vertical = if length_square(dot(vertical, vertical)) > f32::from_bits(0x3586_37bd) { + unit(vertical) + } else { + [0., 1., 0., 0.] + }; + let primitive_flags = provider + .metadata(c.primitive) + .ok_or("Accepted grind primitive lacks native metadata")? + .flags; + result.target = Some(GrindTarget { + edge, + provider_index: c.primitive, + primitive_flags, + orientation, + point: c.point, + vertical_normal: vertical, + }); + result.score = self.score; + return Ok(result); + } + Ok(result) } } +fn dot(a: V, b: V) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn graph8( + data: &Collections, + class: &str, + name: &str, + negative: bool, +) -> Result, String> { + let expected = if negative { + "Sk8::PointNegGraphData8" + } else { + "Sk8::PointGraphData8" + }; + if data.field(class, "default", name)?.type_name != expected { + return Err(format!("Wrong stock graph type {class}/{name}")); + } + let words = data.words::<20>(class, "default", name)?; + Ok(PointGraph { + x: core::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: core::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) +} 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) +fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] +} +fn inverse(square: f32) -> f32 { + let mut r = square.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-square).mul_add(r * r, 1.), r); + } + r +} +fn length_square(square: f32) -> f32 { + if square == 0. { + 0. + } else { + square * inverse(square) + } +} +fn unit(a: V) -> V { + let q = dot(a, a); + if length_square(q) > f32::from_bits(0x3586_37bd) { + scale(a, inverse(q)) + } else { + [0.; 4] + } } diff --git a/crates/skate-game/src/physics/air_trajectory/mod.rs b/crates/skate-game/src/physics/air_trajectory/mod.rs index 49d3f670..3e774bee 100644 --- a/crates/skate-game/src/physics/air_trajectory/mod.rs +++ b/crates/skate-game/src/physics/air_trajectory/mod.rs @@ -1,11 +1,14 @@ //! Real stock-settings and BoardWorld adapter for the player trajectory selector. mod grind; mod settings; +use crate::grind_world::StaticProvider; +pub(crate) use grind::GrindContext; +use std::sync::Arc; mod world; use skate_core::{ air::trajectory::{ - query_trajectory, LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, - TrajectorySelector, + LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, TrajectorySelector, + query_trajectory, }, physics::board_world::BoardWorld, }; @@ -14,12 +17,10 @@ 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>, + grind_settings: grind::Settings, + grind_world: Option>, + nearby_grinds: Vec, } impl AirTrajectoryRuntime { ///Full82E099A0 query shared by trajectory and Footplant callers. @@ -35,11 +36,9 @@ 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(), + grind_world: None, + nearby_grinds: Vec::new(), }) } pub fn launch( @@ -54,11 +53,24 @@ impl AirTrajectoryRuntime { } Ok(launched) } - pub fn update(&mut self, input: SelectorInput, world: &BoardWorld) -> Result { + pub fn bind_grind_world(&mut self, provider: Arc) { + self.grind_world = Some(provider); + self.nearby_grinds.clear(); + } + pub fn update( + &mut self, + input: SelectorInput, + world: &BoardWorld, + context: GrindContext, + ) -> Result { let Some(results) = self.pending_results.take() else { return Ok(self.selector.update_without_completion()); }; - let valid = self.selector.complete_batch_with_grinds( + let provider = self + .grind_world + .as_deref() + .ok_or("Trajectory static grind provider was not registered")?; + let valid = self.selector.complete_batch( &results, input, &self.settings, @@ -67,11 +79,10 @@ impl AirTrajectoryRuntime { prediction, acquire, world, - &self.edges, - &mut self.grind_candidates, + provider, + &mut self.nearby_grinds, input.grind_lock_distance, - self.grind_com_position, - self.grind_board_position, + context, ) }, |start, end, radius| world::line(world, start, end, radius), @@ -81,10 +92,6 @@ impl AirTrajectoryRuntime { } Ok(valid) } - pub fn cancel_pending(&mut self) { - self.pending_results = None; - self.selector.cancel_pending(); - } fn submit(&mut self, world: &BoardWorld) -> Result<(), String> { let results = self .selector diff --git a/crates/skate-game/src/physics/air_trajectory/settings.rs b/crates/skate-game/src/physics/air_trajectory/settings.rs index bd2a3ec9..f34f6a79 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 }) } -pub(super) fn graph8( +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 8970d2ed..d24c694f 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, + air::trajectory::{SurfaceHit, WorldWithoutGrindEdges}, math::Vector3, physics::{ board_world::BoardWorld, - triangle_query::{triangle_segment, TriangleLineHit}, + triangle_query::{TriangleLineHit, triangle_segment}, }, }; type Vector = [f32; 4]; @@ -17,22 +17,21 @@ 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, end: Vector, radius: f32, ) -> Result, String> { - // Native vectors retain a fourth SIMD lane, but the segment predicate, - // broadphase and vec3 conversion below consume XYZ only. That carry lane - // is not a homogeneous coordinate and need not be a finite float. - if !radius.is_finite() - || radius < 0.0 - || start[..3].iter().chain(&end[..3]).any(|v| !v.is_finite()) - { - return Err(format!( - "Non-finite trajectory collision coordinates or invalid radius: start={start:?}, end={end:?}, radius={radius}" - )); + if !radius.is_finite() || radius < 0.0 || start.into_iter().chain(end).any(|v| !v.is_finite()) { + return Err("Non-finite trajectory collision request or invalid radius".into()); } let start = vec3(start); let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); diff --git a/crates/skate-game/src/physics/animated_skeleton.rs b/crates/skate-game/src/physics/animated_skeleton.rs index b330ed95..9ff034a7 100644 --- a/crates/skate-game/src/physics/animated_skeleton.rs +++ b/crates/skate-game/src/physics/animated_skeleton.rs @@ -33,6 +33,9 @@ pub(crate) struct AnimatedSkeleton { pub bone_indices: [usize; 24], pub physics_frames: [AnimationPartTransform; 24], pub animation_board: AnimationPartTransform, + ///Skeleton12560, copied BEFORE board offsets and IK by82BD8CB0..8D10. + pub unadjusted_board: AnimationPartTransform, + landing_on_board_blend: PointGraph<8>, pub animation_hips: AnimationPartTransform, pub motion: SkeletonMotion, pub board_at_y_delta: f32, @@ -109,6 +112,16 @@ impl AnimatedSkeleton { bone_indices, physics_frames, animation_board: IDENTITY, + unadjusted_board: IDENTITY, + landing_on_board_blend: { + let words = data + .words::<20>("physics_animation", "default", "LandingOnDeckBLendVsTime")? + .map(f32::from_bits); + PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + } + }, animation_hips: IDENTITY, motion: SkeletonMotion::default(), board_at_y_delta: 0.0, @@ -129,11 +142,13 @@ impl AnimatedSkeleton { dt: f32, flags_2468: &mut u32, flags_2472: &mut u32, + landing_on_board: Option<(AnimationPartTransform, [f32; 4], u32, f32)>, ) -> Result<(), String> { let trajectory = globals.first().ok_or("Animation has no trajectory bone")?; self.motion .process_trajectory(trajectory, &self.roots.animation_to_world, dt); let mut parts = map_animation_parts(globals, &self.bone_indices, &self.physics_frames)?; + self.unadjusted_board = parts[0]; SkeletonMotion::publish_unadjusted_board(&parts[0], flags_2472); for (target, part) in [15, 19, 3, 7].into_iter().enumerate() { let bone = globals @@ -142,6 +157,22 @@ impl AnimatedSkeleton { self.targets[target] = compose_affine(bone, &self.physics_frames[part]); } landing.animation_com_height = self.record.centre_of_mass[1] - parts[0][3][1]; + if let Some((deck, offset, flags2480, time)) = landing_on_board { + if let Some(adjustment) = + skate_core::physics::skeleton_landing_on_board::pose_adjustment( + &self.roots.world_to_animation, + &deck, + &parts[0], + offset, + self.board_frames.com_velocity[1], + flags2480, + time, + &self.landing_on_board_blend, + ) + { + self.board_offset.refresh_transform(adjustment); + } + } self.landing .update(landing, &self.landing_settings, &mut self.board_offset); self.board_offset.update(&mut parts[0], &mut self.targets); diff --git a/crates/skate-game/src/physics/animation_grind.rs b/crates/skate-game/src/physics/animation_grind.rs new file mode 100644 index 00000000..c83cdc83 --- /dev/null +++ b/crates/skate-game/src/physics/animation_grind.rs @@ -0,0 +1,124 @@ +//! Completed physical publication -> both stock graphs. No state selection. +use crate::graph_host::{motion::MotionHost, motion_grind}; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + graph::conditions::PhysicalStateInputs, + physics::filtered_state::{FilteredStateOutput, GrindState}, + player::input_phase::PhysicalPlayerInput, +}; + +pub(super) fn publish( + host: &mut MotionHost, + physical: &PhysicalPlayerInput, + filtered: Option<&FilteredStateOutput>, + height: f32, + mirrored: bool, + board_forward: [f32; 3], +) -> Result { + let (state, grind, conditions) = + observations(physical, filtered, height, mirrored, board_forward)?; + host.grind_physical = Some(grind); + host.grind_conditions = Some(conditions); + Ok(state) +} + +fn observations( + physical: &PhysicalPlayerInput, + filtered: Option<&FilteredStateOutput>, + height: f32, + mirrored: bool, + board_forward: [f32; 3], +) -> Result< + ( + PhysicalStateInputs, + motion_grind::Physical, + motion_grind::conditions::Physical, + ), + String, +> { + let (grinding, grind) = match filtered { + Some(output) => { + if output.category as u32 != physical.filtered_state_0 { + return Err("Grind graph received inconsistent completed filtered outputs".into()); + } + (output.grinding, output.grind) + } + // FilteredState reset82DE5588: category0, null names and crouch0. + // This is the initial reset publication, not a substitute grind state. + None if physical.filtered_state_0 == 0 => (false, GrindState::default()), + None => return Err("Grind graph requires the completed filtered state owner".into()), + }; + let grind_name = name_text(grind.name)?; + let grind_observation = motion_grind::Physical { + grinding, + grind_name: grind.name, + // Bundle4 is Motion, not Ground. Basic128 is supplied by the + // completed BoardMotionOutput effective basis, not Motion128. + ground_axis: xyz(physical.skateboard.vector_80), + board_axis: board_forward, + ground_flag_273: physical.ground.flag_273 != 0, + // Execution refreshes bit30 for the two branches which consume it. + animation_mirrored: mirrored, + height, + crouch: grind.crouch, + twist: physical.skeleton.twist_504, + }; + let conditions = motion_grind::conditions::Physical { + filtered_grinding_80: grinding, + blunting_136: physical.grinds.words_136_140[0], + approach_268: physical.grinds.animation_chromosome_268[0], + trick_out_240: physical.grinds.trick_out_240, + air_grind_443: physical.air.flag_443 != 0, + air_time_184: physical.air.scalar_184, + dropping_in_324: physical.grinds.dropping_in_324 != 0, + }; + Ok(( + PhysicalStateInputs { + category: physical.filtered_state_0, + grinding, + grind_name, + }, + grind_observation, + conditions, + )) +} + +fn xyz(raw: [u32; 4]) -> [f32; 3] { + [raw[0], raw[1], raw[2]].map(f32::from_bits) +} + +/// Lossless adapter for CURRENT's string-based IsGrinding boundary. Stock grind +/// short names use only the native base38 alphabet 0-9,A-Z,_. This reverses +/// the existing encoder, then validates its round trip; it chooses no name. +fn name_text(name: AttributeName) -> Result { + let mut text = String::new(); + let mut ended = false; + for mut word in name.0 { + for weight in [79_235_168, 2_085_136, 54_872, 1_444, 38, 1] { + let digit = word / weight; + word %= weight; + if digit == 0 { + ended = true; + continue; + } + if ended { + return Err("Filtered grind name contains a nonterminal NUL".into()); + } + let byte = match digit { + 1..=10 => b'0' + (digit as u8 - 1), + 11..=36 => b'A' + (digit as u8 - 11), + 37 => b'_', + _ => return Err("Filtered grind name is outside the stock alphabet".into()), + }; + text.push(byte as char); + } + } + if encode(text.as_bytes()) != name { + return Err("Filtered grind name did not round trip".into()); + } + Ok(text) +} + +#[cfg(test)] +#[path = "animation_grind_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/animation_grind_tests.rs b/crates/skate-game/src/physics/animation_grind_tests.rs new file mode 100644 index 00000000..3ce59d9b --- /dev/null +++ b/crates/skate-game/src/physics/animation_grind_tests.rs @@ -0,0 +1,82 @@ +//! Independent publication tests; these do not simulate physical producers. +use super::*; +use skate_core::physics::filtered_state::FilteredCategory; + +#[test] +fn encoded_name_boundary_is_lossless_and_rejects_malformed_names() { + for text in [ + "", + "BF_50_50", + "FS_BLUNT", + "BS_NOSESLIDE", + "0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ", + ] { + let name = encode(text.as_bytes()); + assert_eq!(encode(name_text(name).unwrap().as_bytes()), name); + } + assert!(name_text(AttributeName([u32::MAX, 0, 0, 0, 0])).is_err()); + assert!(name_text(AttributeName([0, 79_235_168, 0, 0, 0])).is_err()); +} + +#[test] +fn completed_output_fields_reach_both_graph_boundaries_without_proxies() { + let mut p = PhysicalPlayerInput::default(); + p.filtered_state_0 = FilteredCategory::Grind as u32; + p.skateboard.vector_80 = [1.0f32, 2.0, 3.0, 4.0].map(f32::to_bits); + let board_forward = [5.0, 6.0, 7.0]; + p.ground.flag_273 = 2; + p.skeleton.twist_504 = -0.7; + p.grinds.words_136_140[0] = 4; + p.grinds.trick_out_240 = 2; + p.grinds.animation_chromosome_268[0] = 1; + p.grinds.dropping_in_324 = 3; + p.air.flag_443 = 5; + p.air.scalar_184 = 0.15; + let filtered = FilteredStateOutput { + category: FilteredCategory::Grind, + previous_category: FilteredCategory::Air, + grinding: true, + grind: GrindState { + name: encode(b"BF_50_50"), + crouch: 0.3, + ..GrindState::default() + }, + last_grind_distance: 0.0, + }; + let (state, grind, conditions) = + observations(&p, Some(&filtered), 0.8, true, board_forward).unwrap(); + assert!(state.grinding); + assert_eq!(encode(state.grind_name.as_bytes()), filtered.grind.name); + assert_eq!(grind.grind_name, filtered.grind.name); + assert_eq!(grind.ground_axis, [1.0, 2.0, 3.0]); + assert_eq!(grind.board_axis, [5.0, 6.0, 7.0]); + assert!(grind.ground_flag_273 && grind.animation_mirrored); + assert_eq!((grind.height, grind.crouch, grind.twist), (0.8, 0.3, -0.7)); + use motion_grind::conditions::Condition; + for condition in [ + Condition::BluntingBackslash, + Condition::Approach { forwards: true }, + Condition::TrickOutType { value: 2 }, + Condition::LandingIntoGrind, + Condition::DroppingIn, + ] { + assert!(condition.evaluate(&conditions)); + } + // No physical state/contact/holding-board proxies were supplied above. + assert_eq!(p.state.state_16, 0); + assert_eq!(p.grinds.flag_318, 0); + assert_eq!(p.off_board.flag_304, 0); + assert!(observations(&p, None, 0.8, true, board_forward).is_err()); + p.filtered_state_0 = FilteredCategory::Air as u32; + assert!(observations(&p, Some(&filtered), 0.8, true, board_forward).is_err()); +} + +#[test] +fn initial_reset_is_not_an_invented_grind_publication() { + let (state, grind, _) = + observations(&PhysicalPlayerInput::default(), None, 0.0, false, [0.0; 3]).unwrap(); + assert!(!state.grinding && !grind.grinding); + assert_eq!(state.category, 0); + assert_eq!(state.grind_name, ""); + assert_eq!(grind.crouch, 0.0); +} diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index 6cace9ab..25455939 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -1,5 +1,7 @@ //! The production physical-output -> stock graph -> next physical-input phase. //! Native actor phases82592FD8/82593128/82593230/82593640 preserve this order. +#[path = "animation_grind.rs"] +mod grind; #[path = "animation_phase_packet.rs"] mod packet; use super::{GamePhysics, PlayerControls, skater::SkaterRuntime}; @@ -11,7 +13,7 @@ use skate_core::{ physical_feedback::{ControlFeedback, PhysicalFeedback, ReckoningFeedback}, riding_fakie, }, - graph::conditions::{ConditionInputs, PhysicalStateInputs, PushBrakeInputs}, + graph::conditions::{ConditionInputs, PushBrakeInputs}, math::Vector3, physics::board::BodyId, riding::push_behaviors::PushFootFrame, @@ -31,10 +33,44 @@ pub(crate) fn advance( let p = &skater.player_input.processed; let physical = &skater.player_input.physical; skater.animation.motion.manual_exit = Some(physical.animation.manual_opposition_168 != 0); + // ProcessOutput82DB7250/7258 publishes shared Biped720/716 as + //OffBoard80/84. BipedCadence/MatchCadence/LocoState consume that completed + //physical packet, preserving the publication boundary across transitions. + // The native publication is unconditional, including on-board frames + //when a dismount node can capture MatchCadence before state500 is entered. + skater.animation.motion.offboard_cadence_phase = Some(physical.off_board.cadence_phase_80); + skater.animation.motion.offboard_locomotion_state = + Some(physical.off_board.locomotion_state_84); + // GroundSlopeType82BA7E80 and IsBipedGroundThin82BA8110 read the + // completed OffBoard88/330 directly, without a state/category gate. + skater.animation.motion.ground_slope_type = Some(physical.off_board.kind_88); + skater.animation.motion.biped_ground_thin = Some(physical.off_board.flag_330 != 0); + skater.animation.action.dropping_in = Some(physical.grinds.dropping_in_324 != 0); let deck = physics.board.part_transforms()[BodyId::Deck.index()]; let (fakie, mirrored) = skater.animation.stance(); + // ToggleBoard consumes the completed FillPhysOut publication from the + // preceding tick. Keep this input explicit so unsupported behavior cannot + // silently run against fabricated board state. + skater.animation.motion.toggle_board_physical = + Some(crate::graph_host::motion_toggle_board::Physical { + grabbing_object: physical.off_board.flag_304 != 0, + holding_board: physical.off_board.flag_311 != 0, + free_board: physical.off_board.free_board_312 != 0, + retrieval_blocked: skater.player_state.state_flags[87 - 52], + //ToggleBoard82BA968C consumes returning313, not retrieval323. + retrieval_active: physical.off_board.returning_board_313 != 0, + yaw_radians: physical.off_board.angle_36, + pitch_radians: physical.off_board.angle_40, + }); + skater.animation.motion.runout_physical = Some(crate::graph_host::motion_runout::Observation { + offboard_flag_331: physical.off_board.trajectory_valid_331 != 0, + offboard_velocity_128: physical.off_board.vector_64.map(f32::from_bits), + reckoning_velocity_16: physical.reckoning.vector_16.map(f32::from_bits), + reckoning_up_96: physical.reckoning.vector_96.map(f32::from_bits), + skeleton_vector_0: skater.skeleton_input.drive_frames[0][0], + animation_mirrored: mirrored, + }); let feedback = skater.physical_feedback; - skater.animation.motion.bump_acceleration = Some(feedback.ground_acceleration); skater.animation.motion.wipeout_physical = Some(crate::graph_host::motion_wipeout::Physical { over_599: physical.skeleton.over_599 != 0, collision_time_144: physical.animation.collision_time_144, @@ -98,7 +134,6 @@ pub(crate) fn advance( retrieving_board: physical.off_board.retrieving_board_323 != 0, dropping_board: physical.off_board.dropping_board_322 != 0, in_biped_air: physical.off_board.flag_328 != 0, - can_land_on_board: physical.off_board.flag_316 != 0, hippy_hurdling: physical.off_board.hippy_hurdling_317 != 0, handplant_flags: physical.air.handplant_flags_324, handplant_time: physical.air.handplant_time_320, @@ -110,9 +145,28 @@ pub(crate) fn advance( skitch_transition_time: profile.skitch_transition_time, time_to_land: physical.air.scalar_184, time_to_land_valid: physical.air.known_air_valid_437 != 0, + offboard_trajectory_time: physical.off_board.trajectory_time_120, + offboard_trajectory_valid: physical.off_board.trajectory_valid_331 != 0, trucks_or_deck_contact: physical.collision.flag_3472 != 0 || physical.collision.flag_3475 != 0, offboard_time_to_land: physical.off_board.scalar_32, - tricks_blocked_on_stairs: physical.animation.tricks_blocked_on_stairs_166 != 0, + offboard_air_scalar_92: physical.off_board.scalar_92, + offboard_air_translation: physical.off_board.vector_96.map(f32::from_bits), + offboard_landing_normal: physical.off_board.vector_192.map(f32::from_bits), + offboard_committed_to_motion: physical.off_board.flag_329 != 0, + offboard_obstacle_distance: physical.off_board.scalar_112, + offboard_edge_distance: physical.off_board.distance_116, + //ApexReached82BA5C78 reads Reckoning16.Y, not Air436. + reached_apex: f32::from_bits(physical.reckoning.vector_16[1]) < 0.0, + can_land_on_board: physical.off_board.flag_316 != 0, + landing_turning: physical.off_board.flag_318 != 0, + grind_contact: physical.grinds.flag_318 != 0 || physical.grinds.flag_322 != 0, + wheel_contact: physical + .collision + .wheel_contact_3296_3299 + .iter() + .any(|&v| v != 0), + tricks_blocked_on_stairs: false, + moving_object: physical.state.state_16 == 502, }, ); skater.animation.action.gameplay_conditions = skater.animation.motion.gameplay_conditions; @@ -123,6 +177,7 @@ pub(crate) fn advance( board_reckoning_z: physics.riding.reckoning_frames.ground[2], board_reckoning: physics.riding.reckoning_frames.ground, }); + skater.animation.motion.bump_acceleration = Some(feedback.ground_acceleration); skater.animation.motion.gesture_physical = Some(crate::graph_host::motion_native::GesturePhysical { //Original82DB74D8, not the similarly numbered Air output byte. @@ -150,39 +205,6 @@ pub(crate) fn advance( } else { [0.0; 4] }; - skater.animation.motion.toggle_board_physical = - crate::graph_host::motion_toggle_board::Physical { - blocked: physical.off_board.flag_304 != 0, - held: physical.off_board.flag_311 != 0, - returning: physical.off_board.returning_board_313 != 0, - forbid_retrieve: skater.player_state.state_flags[87 - 52], - angle: physical.off_board.board_angle_36, - pitch: physical.off_board.board_angle_40, - mirrored: skater - .animation - .motion - .animation - .skater_animation_flags - .unwrap_or(0) - & 0x40000000 - != 0, - }; - //82BA5368: Ground80.Y < .64 or Skeleton598 (IK3185,82BE2144). - skater.animation.motion.disable_dismount = physics.riding.reckoning_frames.ground[1][1] - < f32::from_bits(0x3f23d70a) - || skater.foot_ik.state.contacts.support_failed_this_update; - skater.animation.motion.offboard_phase = physical.off_board.phase_80; - skater.animation.motion.offboard_output = physical.off_board; - skater.animation.motion.offboard_locomotion = physical.off_board.locomotion_84; - //Player Fill82DB6EC0: Biped348/320/328 -> OffBoard256/260/264. - let support = &skater.offboard.controller.state.motion; - skater.animation.motion.offboard_support = [ - support.support_speed_348, - support.filtered_local_acceleration_320[0], - support.filtered_local_acceleration_320[2], - ]; - skater.animation.motion.offboard_slope = physical.off_board.kind_88; - skater.animation.motion.offboard_ground_thin = physical.off_board.flag_330 != 0; skater.animation.motion.shove_physical = Some(crate::graph_host::motion_shove::ShovePhysical { interaction_trigger, direction, @@ -190,34 +212,17 @@ pub(crate) fn advance( board_on_ground: physical.off_board.flag_311 != 0, animation_height: feedback.crouching.animation_height_72, }); - let grinding=skater.player_state.filtered_output.as_ref().is_some_and(|f|f.grinding); - let grind_name=if grinding {physics.grind.name.clone()} else {String::new()}; - //FillSkeleton82BE1AE8 uses the vector between physical parts10/6, - //Processed352 and Reckoning1152, then wraps the signed angle to +/-pi. - let a=skater.skeleton.record.pose[10][3]; - let b=skater.skeleton.record.pose[6][3]; - let lateral=Vector3::new(a[0]-b[0],a[1]-b[1],a[2]-b[2]); - let forward=skater.player_input.toolkit.as_ref().map(|t| t.forward) - .unwrap_or([0.,0.,1.,0.]); - let mut twist=skate_core::riding::collision_response::signed_angle( - Vector3::new(forward[0],forward[1],forward[2]),lateral,physics.riding.reckoning.up); - let turns=twist* f32::from_bits(0x3e22f983); - let fraction=turns-turns.floor(); - twist=(fraction-if fraction>0.5 {1.0} else {0.0})*f32::from_bits(0x40c90fdb); - if mirrored {twist=if twist>=0.0 {std::f32::consts::PI-twist} else {-std::f32::consts::PI-twist};} - skater.animation.motion.grind_physical=crate::graph_host::motion_grind::Physical { - name:grind_name.clone(),grinding,crouch:physics.grind.output().crouch,twist, - facing_backwards: physics.grind.name.starts_with("BF_"), - dropping_in: physical.grinds.dropping_in_324 != 0, - }; - skater.animation.action.dropping_in = Some(physical.grinds.dropping_in_324 != 0); + let physical_state = grind::publish( + &mut skater.animation.motion, + physical, + skater.player_state.filtered_output.as_ref(), + feedback.crouching.animation_height_72, + mirrored, + physics.riding.motion.effective_basis.columns[2], + )?; let conditions = ConditionInputs { speeds: Some(physics.riding.graph_speeds()), - physical_state: Some(PhysicalStateInputs { - category: physical.filtered_state_0, - grinding, - grind_name, - }), + physical_state: Some(physical_state), time_since_last_input: Some(p.time_since_last_input_2748), mirrored: Some(mirrored), riding_fakie: Some(fakie), @@ -234,15 +239,6 @@ pub(crate) fn advance( effective_z = effective_z.map(|v| -v); } let mut effective_x = skater.animated_skeleton.roots.animation_to_world[0]; - skater.animation.motion.runout_physical = Some(crate::graph_host::motion_runout::Physical { - forward: effective_z, - velocity: physical.reckoning.vector_16.map(f32::from_bits), - up: physical.reckoning.vector_96.map(f32::from_bits), - // OffBoard331 remains reset in the currently implemented riding/air states, - // matching the camera publication. Biped trajectory owners must publish - // OffBoard128 here when they enable that alternate native output. - trajectory_velocity: None, - }); if p.flags_2476 & 4 != 0 { effective_x = effective_x.map(|v| -v); } @@ -291,31 +287,34 @@ pub(crate) fn advance( physical_28_byte75: physical.state.category_12 == 500, time_since_teleport: skater.animation.motion.riding.time_since_teleport, }; - //Native Listener::Fill825999F0, after derived controller sampling and before AG. + // Fill825999F0/8259C260 publishes discrete off-board inputs before the + // stock ActionGraph. DerivedControllerInput has already advanced once. let mut action_intents = controls.action_intents.clone(); - use skate_core::input::offboard_intentions::{ - AnalogObservation, produce_analog_world, produce_discrete, - }; - let biped = &skater.offboard.controller.state.contact; - for intent in produce_discrete( + for intent in skate_core::input::grind_intentions::produce(&controls.controller) { + action_intents.insert(intent.name, intent.value); + } + // Fill8259AFFC..B088 emits the four analog AG inputs every frame. + // Stock AG maps OB_Mag to OB_SteerMagnitude; MG attaches that as ob_Mag + // and attaches OB_BipedWorldX/Z unchanged. ProcessOutput82DB7288..729C + // publishes the shared Biped708/400 contact gate/direction as333/288. + let contact = &skater.biped_ground.controller.state.contact; + for intent in skate_core::input::offboard_intentions::produce_analog( + &controls.controller, + skate_core::input::offboard_intentions::AnalogObservation { + effective_skeleton_z: effective_z, + biped_correction: contact.active.then_some(contact.direction), + }, + ) { + action_intents.insert(intent.name, intent.value); + } + for intent in skate_core::input::offboard_intentions::produce_discrete( &controls.controller, controls.actor_flags, physical.air.use_air_reckoning_452 != 0, - ) - .into_iter() - .chain(produce_analog_world( - controls.offboard_direction.unwrap_or_else(|| { - let words = controls.controller.words(); - [f32::from_bits(words[7]), 0., f32::from_bits(words[8]), 0.] - }), - AnalogObservation { - effective_skeleton_z: p.effective_anim_transform_192[2].map(f32::from_bits), - biped_correction: biped.active.then_some(biped.direction), - }, - )) { + ) { action_intents.insert(intent.name, intent.value); } - let mut output = AnimationPhaseOutput::new(feedback); + let mut output = AnimationPhaseOutput::new(); skater.animation.advance( graphs, physics.settings.step.simulation.time_step, diff --git a/crates/skate-game/src/physics/animation_phase_packet.rs b/crates/skate-game/src/physics/animation_phase_packet.rs index bec89da1..84907da2 100644 --- a/crates/skate-game/src/physics/animation_phase_packet.rs +++ b/crates/skate-game/src/physics/animation_phase_packet.rs @@ -1,12 +1,9 @@ //! Owned PlayerInput packet storage; original actor publication825937EC. use skate_core::{ - animation::{ - output::{ - actor_packet::ExternalPhysicsInput, - packet_reset::{AdditionalResetFields, RESET_POSE}, - physics_packet::PhysicsPosePacket, - }, - physical_feedback::PhysicalFeedback, + animation::output::{ + actor_packet::ExternalPhysicsInput, + packet_reset::{AdditionalResetFields, RESET_POSE}, + physics_packet::PhysicsPosePacket, }, input::animation_packet::AnimationPacketFields, player::input_phase::AnimationInputPacket, @@ -68,8 +65,6 @@ impl AnimationProfile { } pub(crate) struct AnimationPhaseOutput { - /// Camera reuses this result; never run the conditioner a second time. - pub feedback: PhysicalFeedback, pub(super) reset: AdditionalResetFields, publication: AnimationPacketFields, external: ExternalPhysicsInput, @@ -79,9 +74,8 @@ pub(crate) struct AnimationPhaseOutput { // Actor825938FC/393C consumes these request bits after publication. } impl AnimationPhaseOutput { - pub(super) fn new(feedback: PhysicalFeedback) -> Self { + pub(super) fn new() -> Self { Self { - feedback, //Reserved storage: native reset writes every field before use. reset: AdditionalResetFields { compression: 0.0, @@ -163,7 +157,8 @@ impl AnimationPhaseOutput { } ///Actor825926F8 reply ->10704/10768/10784, then82DB5BE0 maps physical input. pub(super) fn publish_external_reset( - &mut self, reply: skate_core::animation::output::actor_packet::ExternalReset, + &mut self, + reply: skate_core::animation::output::actor_packet::ExternalReset, ) { self.publication.matrix_10704 = reply.transform; self.publication.byte_10768 = reply.byte64; diff --git a/crates/skate-game/src/physics/biped_air.rs b/crates/skate-game/src/physics/biped_air.rs new file mode 100644 index 00000000..7ea8b710 --- /dev/null +++ b/crates/skate-game/src/physics/biped_air.rs @@ -0,0 +1,99 @@ +//! Distinct State501 lifecycle: original S3 82D2E5C0/EF38/FFF8/30300/EF20. +mod input; +mod post; +mod publication; +mod settings; +mod update; +use super::{GamePhysics, SkaterRuntime}; +pub(crate) use post::post; +use skate_core::player::offboard::{ + biped_air::recovered::{State, post::Settings}, + controller::PlacementInput, +}; +use skate_data::collections::Collections; +pub(crate) use update::update; +pub(crate) struct BipedAir { + pub state: State, + checks: Settings, +} +impl BipedAir { + pub(crate) fn load(data: &Collections) -> Result { + Ok(Self { + state: State::default(), + checks: settings::load(data)?, + }) + } +} +///82DB3C78 at the early fixed-step phase, before current ProcessInput. +///82859E70 completes earlier trajectory work atA06C/A080 and invokes virtual +///slot16 atA0D0. Actual vtable82328688+16 points to82DB3C78. +pub(crate) fn consume_selector( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let context = input::context(skater); + //82C209E4 supplies the stock Air clearance parameter, not guessed geometry. + let clearance = skater.offboard_air_selector.settings.deck_center_to_truck; + skater + .offboard_air_selector + .consume(&physics.world, context, clearance) + .map(|_| ()) + .map_err(str::to_owned) +} +pub(crate) fn enter(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.biped_air.state.reset(); + //State68 is the SAME manager consumed by state503; state64 is Biped. + skater.landing_deck.reset(); + let entry = input::enter(skater); + skater.biped_air.state.begin_enter_after_reset(entry); + let mut feet = std::mem::take(&mut skater.offboard_feet); + super::offboard::air_feet::enter(skater, &mut feet); + skater.offboard_feet = feet; + skater + .ground_lifecycle + .skeleton_controller + .request(4, &mut skater.skeleton_collision)?; + if !skater + .offboard_air_selector + .core + .sampling + .preinitialized_8494 + { + let packet = input::launch_packet(skater)?; + input::submit(physics, skater, packet)?; + } + let p = &skater.player_input.processed; + //82D7B7A0 is the common Biped placement, not Ground Enter/Update. + skater.biped_ground.controller.place(PlacementInput { + frame: entry.animation_frame, + velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + body_position: entry.body_position_15872, + current_state: p.state_2508, + previous_state: p.state_2504, + previous_frame: p + .effective_anim_transform_192 + .map(|v| v.map(f32::from_bits)), + }); + skater.biped_air.state.finish_enter(input::enter(skater)); + Ok(()) +} +pub(crate) fn exit(skater: &mut SkaterRuntime) { + skater + .biped_air + .state + .exit(&mut skater.offboard_air_selector.core.sampling); +} +pub(crate) fn fill(skater: &mut SkaterRuntime) -> Result<(), String> { + let physical = &mut skater.player_input.physical; + publication::publish(skater.biped_air.state.output(), &mut physical.off_board); + super::offboard::air_feet::publish(&skater.offboard_feet, &mut physical.off_board); + //82D7948C/98: destination is PhysOut24 collision, NOT Skeleton20. + skater + .landing_deck + .publish(super::offboard::landing_deck::PublishTargets { + off_board: &mut physical.off_board, + skeleton_position_48: &mut physical.collision.vector_48, + skeleton_flag_3482: &mut physical.collision.flag_3482, + }); + Ok(()) +} diff --git a/crates/skate-game/src/physics/biped_air/input.rs b/crates/skate-game/src/physics/biped_air/input.rs new file mode 100644 index 00000000..8c29144a --- /dev/null +++ b/crates/skate-game/src/physics/biped_air/input.rs @@ -0,0 +1,104 @@ +//! Direct processed/physical inputs for the distinct BipedAir lifecycle. +use super::{GamePhysics, SkaterRuntime}; +use skate_core::{ + physics::skeleton_animation_record::compose_affine, + player::offboard::{ + air_launch::{DepartureGeometry, Packet, Processed}, + air_selector::Context, + biped_air::recovered::{EnterInput, Frame, height::HeightInput}, + }, +}; +pub(super) fn context(skater: &SkaterRuntime) -> Context { + let p = &skater.player_input.processed; + Context { + selection_flags_2948: p.actor_query_2948, + matching_group_2952: p.actor_query_2952 as i32, + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + forward_224: p.effective_anim_transform_192[2].map(f32::from_bits), + } +} +pub(super) fn effective_frame(skater: &SkaterRuntime) -> Frame { + //82BE3650: actual animation root, with effective stance axes0/2. + let mut frame = skater.animated_skeleton.roots.animation_to_world; + if skater.player_input.processed.flags_2476 & 4 != 0 { + for axis in [0, 2] { + frame[axis] = frame[axis].map(|v| -v); + } + } + frame +} +pub(super) fn enter(skater: &SkaterRuntime) -> EnterInput { + let p = &skater.player_input.processed; + EnterInput { + animation_frame: effective_frame(skater), + body_position_15872: skater.animated_skeleton.board_frames.com_frame[3], + body_position_15936: skater.animated_skeleton.board_frames.lifted_com_frame[3], + position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + flags_2484: p.flags_2484, + } +} +pub(super) fn height(skater: &SkaterRuntime) -> HeightInput { + let p = &skater.player_input.processed; + let root = skater.animated_skeleton.roots.animation_to_world; + HeightInput { + bone15: compose_affine(&root, &skater.animated_skeleton.record.pose[15])[3], + bone19: compose_affine(&root, &skater.animated_skeleton.record.pose[19])[3], + //82BE3170 reads mapped physical target12624; NOT record6480. + bone1: compose_affine(&root, &skater.skeleton_input.drive_frames[1])[3], + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + } +} +pub(super) fn launch_packet(skater: &SkaterRuntime) -> Result { + let p = &skater.player_input.processed; + let board = skater + .player_input + .toolkit + .as_ref() + .ok_or("BipedAir launch requires the completed BoardToolkit")?; + let input = Processed { + board_position_112: board.deck[3], + forward_224: p.effective_anim_transform_192[2].map(f32::from_bits), + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + velocity_912: p.vectors_880_896_912_928_944[2].map(f32::from_bits), + departure_geometry: Some(DepartureGeometry { + point_1120: p.grind.point_1120.map(f32::from_bits), + axis_1136: p.grind.direction_1136.map(f32::from_bits), + }), + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + previous_state_2504: p.state_2504, + current_state_2508: p.state_2508, + current_category_2512: p.category_2512, + previous_category_2516: p.category_2516, + raw_x_2692: skater.animation_input.extra.biped_world_x, + raw_z_2688: skater.animation_input.extra.biped_world_z, + }; + let (turn, settings) = skater.biped_ground.air_settings(); + skater + .biped_air + .state + .launch_packet( + &input, + &skater.biped_ground.controller.state, + &turn, + settings, + ) + .map_err(str::to_owned) +} +pub(super) fn submit( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + packet: Packet, +) -> Result<(), String> { + let context = context(skater); + let g = physics.settings.step.simulation.gravity_acceleration; + skater + .offboard_air_selector + .launch(&physics.world, packet, [g.x, g.y, g.z, 0.], context) + .map_err(str::to_owned) +} diff --git a/crates/skate-game/src/physics/biped_air/post.rs b/crates/skate-game/src/physics/biped_air/post.rs new file mode 100644 index 00000000..8da1678b --- /dev/null +++ b/crates/skate-game/src/physics/biped_air/post.rs @@ -0,0 +1,25 @@ +//! State501 Post82D2FFF8, after completed physical skeleton feedback. +use super::SkaterRuntime; +use skate_core::player::{offboard::biped_air::recovered::post::PostInput, wipeout::Frame}; +pub(crate) fn post(skater: &mut SkaterRuntime, frame: Frame) -> Result<(), String> { + //82D30018..30: synchronize the SAME manager state503 will consume. + skater.landing_deck.post_physics(&skater.player_input)?; + let p = &skater.player_input.processed; + let mode = skater.wipeout.mode(p)?; + skater.biped_air.state.post_physics( + PostInput { + flags_2484: p.flags_2484, + state_timer_2664: p.state_timer_2664, + forward_224: p.effective_anim_transform_192[2].map(f32::from_bits), + side_192: p.effective_anim_transform_192[0].map(f32::from_bits), + input_2708: skater.animation_input.extra.look_y, + input_2704: skater.animation_input.extra.look_x, + }, + &skater.biped_air.checks, + &frame, + mode, + skater.skeleton_input.root_velocity, + &mut skater.wipeout.state, + ); + Ok(()) +} diff --git a/crates/skate-game/src/physics/biped_air/publication.rs b/crates/skate-game/src/physics/biped_air/publication.rs new file mode 100644 index 00000000..39e7f779 --- /dev/null +++ b/crates/skate-game/src/physics/biped_air/publication.rs @@ -0,0 +1,25 @@ +//! Recovered output adapter; switches with the complete lifecycle migration. +pub(crate) fn publish( + output: skate_core::player::offboard::biped_air::recovered::OffBoardOutput, + off_board: &mut skate_core::player::input_phase::OffBoardOutputFields, +) { + //82D30300 writes OffBoard, not the overlapping on-board Air packet. + //Common Biped cadence80/state84 publication remains coordinator-owned. + off_board.scalar_32 = output.scalar_32; + off_board.vector_64 = output.vector_64.map(f32::to_bits); + off_board.scalar_92 = output.scalar_92; + off_board.vector_96 = output.vector_96.map(f32::to_bits); + off_board.word_144 = output.word_144; + off_board.scalar_148 = output.scalar_148; + off_board.scalar_152 = output.scalar_152; + off_board.scalar_156 = output.scalar_156; + off_board.vector_160 = output.vector_160.map(f32::to_bits); + off_board.vector_176 = output.vector_176.map(f32::to_bits); + off_board.vector_192 = output.vector_192.map(f32::to_bits); + off_board.vector_208 = output.vector_208.map(f32::to_bits); + off_board.vector_224 = output.vector_224.map(f32::to_bits); + off_board.vector_240 = output.vector_240.map(f32::to_bits); + off_board.flag_320 = u8::from(output.flag_320); + off_board.flag_328 = u8::from(output.flag_328); + off_board.trajectory_valid_331 = u8::from(output.flag_331); +} diff --git a/crates/skate-game/src/physics/biped_air/settings.rs b/crates/skate-game/src/physics/biped_air/settings.rs new file mode 100644 index 00000000..06248e68 --- /dev/null +++ b/crates/skate-game/src/physics/biped_air/settings.rs @@ -0,0 +1,21 @@ +//! Stock physics_wipeout fields read by82D8FEE8/82D90000 only. +use skate_core::player::offboard::biped_air::recovered::post::{Settings, checks::Thresholds}; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result { + let f = |name| data.float("physics_wipeout", "default", name); + Ok(Settings { + skeleton_air: Thresholds { + squash: f("Wipeout_AirMaxSquash")?, + displacement: f("Wipeout_AirSkeletonMaxDisp")?, + body_contact: f("Wipeout_AirSkeletonMaxContact")?, + arm_contact: f("Wipeout_AirSkeletonMaxContactArms")?, + }, + offboard_air: Thresholds { + squash: f("Wipeout_OB_MaxSquash")?, + displacement: f("Wipeout_OB_Air_SkelMaxDisp")?, + body_contact: f("Wipeout_OB_Air_SkelMaxContact")?, + arm_contact: f("Wipeout_OB_SkeletonMaxContactArms")?, + }, + offboard_min_speed: f("Wipeout_OB_Air_MinSpeed")?, + }) +} diff --git a/crates/skate-game/src/physics/biped_air/update.rs b/crates/skate-game/src/physics/biped_air/update.rs new file mode 100644 index 00000000..dd3be3f3 --- /dev/null +++ b/crates/skate-game/src/physics/biped_air/update.rs @@ -0,0 +1,143 @@ +//! Ordered82D2EF38, including actual skeleton/selector/landing consumers. +use super::{GamePhysics, SkaterRuntime, input}; +use skate_core::player::offboard::biped_air::recovered::{DT, State, height::projected_height}; +pub(crate) fn update( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + _controls: &super::super::PlayerControls, +) -> Result<(), String> { + let p = skater.player_input.processed; + skater.ground_lifecycle.skeleton_controller.request( + if p.flags_2484 & 2 != 0 { 7 } else { 4 }, + &mut skater.skeleton_collision, + )?; + let frame = skater.biped_air.state.begin_update(); + let mut feet = std::mem::take(&mut skater.offboard_feet); + super::super::offboard::air_feet::update_air(skater, &mut feet); + skater.offboard_feet = feet; + //82C040F0(0): shared board steering helper, not Ground locomotion. + skater.ground.steering.update( + 0., + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + //82D2EFF0/82D2F014 load processed+0xB40 (AnimEndCOM), not + //AnimTrans at0xB30. These are distinct stock animation attributes. + if let Some(adjustment) = skater + .biped_air + .state + .animation_adjustment(skater.animation_input.fields.animation_end_com) + { + skater.offboard_air_selector.adjust_animation( + frame.wrapping_sub(1), + adjustment, + skater.biped_air.state.frame_144, + ); + } + skater.biped_air.state.result.reset(); + skater + .offboard_air_selector + .sample(frame, DT, &mut skater.biped_air.state.result); + skater + .biped_air + .state + .orient_sample(skater.animation_input.extra.biped_start_angle, p.flags_2476); + let up = p.vectors_544_560_592_608[0].map(f32::from_bits); + let position = p.vectors_544_560_592_608[2].map(f32::from_bits); + let response = skater.biped_air.state.collision_response( + skater.collision_extra_errors, + up, + skater + .offboard_air_selector + .core + .sampling + .restart_allowed_8493, + ); + if response.request_52 { + skater.wipeout.state.request(32, 0.); + } + if let Some(normal) = response.restart { + let packet = input::launch_packet(skater)?; + let feet = input::height(skater); + let height = projected_height(feet.bone15, feet.bone19, position, up); + let packet = skater + .biped_air + .state + .restart_packet(packet, normal, position, up, height); + input::submit(physics, skater, packet)?; + let effective = input::effective_frame(skater); + skater.biped_air.state.finish_restart(effective, normal); + } + skater.biped_air.state.correct_restarted_sample(); + let height = input::height(skater); + skater.biped_air.state.correct_height(height); + let context = input::context(skater); + skater + .offboard_air_selector + .requery(&physics.world, context, p.flags_2472, p.flags_2488) + .map_err(str::to_owned)?; + skeleton(physics, skater)?; + skater.biped_air.state.update_cadence( + &mut skater.biped_ground.controller.state, + skater.animation_input.fields.cadence_end_percent, + ); + let scene = super::super::offboard::contact_toolkit::StaticScene::new(&physics.world) + .map_err(str::to_owned)?; + skater.landing_deck.assist( + &scene, + &skater.player_input, + State::landing_assist_limit(p.state_timer_2664), + )?; + skater.biped_air.state.finish_landing_latch(); + Ok(()) +} +fn skeleton(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + use super::super::{ + input_phase, offboard::skeleton_air, skeleton_input_runtime::SkeletonOwners, + }; + let board_forward = skater + .player_input + .toolkit + .as_ref() + .ok_or("BipedAir Skeleton requires the completed BoardToolkit")? + .deck[2]; + let collision = input_phase::collision(skater); + let state = &skater.biped_air.state; + let input = skeleton_air::Input { + frame_208: state.frame_208, + trajectory_position_272: state.result.position_272, + body_target_416: state.body_target_416, + lift_436: state.body_offset_436, + board_forward_96: board_forward, + }; + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + //82BDDFFC..82BDE010 calls the SAME82D8E3E0 as Ground. + //Only this common helper is borrowed; Air owns all movement/root updates. + let reckoning = &skater.biped_ground; + let air_reckoning = &mut skater.air_reckoning; + let riding = &mut physics.riding; + skater.skeleton_input.update_biped_air( + &mut skater.skeleton_air, + &mut physics.board, + input, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + |update, processed, body_spin| { + reckoning.finish_reckoning(update, riding, air_reckoning, processed, body_spin); + Ok(()) + }, + )?; + Ok(()) +} diff --git a/crates/skate-game/src/physics/biped_ground.rs b/crates/skate-game/src/physics/biped_ground.rs new file mode 100644 index 00000000..4bab9bf6 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground.rs @@ -0,0 +1,394 @@ +use super::{GamePhysics, SkaterRuntime}; +mod entry; +pub(crate) mod feet; +pub(crate) mod grab; +pub(crate) mod grab_runtime; +pub(crate) mod ground_board; +mod lifecycle; +pub(crate) mod services; +pub(crate) mod sync; +pub(crate) use lifecycle::{fill, post, publish_fields}; +use skate_core::player::offboard::{contact_toolkit, ground_entry, ground_job}; +pub(crate) struct Owner { + pub controller: skate_core::player::offboard::controller::Controller, + pub result: Option, + pub ground: ground_entry::State, + pub contact: contact_toolkit::ContactPrefix, + pub geometry_adjustment: Option, + skeleton_state: super::offboard::skeleton_ground::State, + geometry: super::offboard::ground_geometry::State, + movement_vs_stick_angle: skate_core::point_graph::PointGraph<8>, + turn_vs_stick_angle: skate_core::point_graph::PointGraph<8>, + air_launch: skate_core::player::offboard::air_launch::Settings, + collision_settings: skate_core::player::offboard::ground_lifecycle::CollisionSettings, + pub grab_settings: skate_core::player::offboard::ground_sync::BoardSettings, +} + +impl Owner { + ///Only the shared82D8E3E0 callee, not Ground movement/lifecycle. + ///Ground82BDE2CC and Air82BDDFFC both supply enable-body-spin r7=1. + ///Each caller supplies its own native up/forward/blend and phase ordering. + pub(crate) fn finish_reckoning( + &self, + update: super::offboard::skeleton_ground::ReckoningUpdate, + riding: &mut super::riding_outputs::RidingOutputs, + air: &mut super::air_reckoning::AirReckoning, + processed: &skate_core::player::input_phase::ProcessedPhysicsInput, + physical_body_spin: f32, + ) -> skate_core::player::offboard::ground_reckoning::Output { + self.skeleton_state + .finish_reckoning(update, riding, air, processed, physical_body_spin) + } + + pub(crate) fn air_settings( + &self, + ) -> ( + skate_core::point_graph::PointGraph<8>, + skate_core::player::offboard::air_launch::Settings, + ) { + (self.turn_vs_speed(), self.air_launch) + } + + fn turn_vs_speed(&self) -> skate_core::point_graph::PointGraph<8> { + self.controller.settings.movement_velocity.turn_vs_speed + } + + pub(crate) fn load( + data: &skate_data::collections::Collections, + metadata: &skate_data::animation_metadata::AnimationMetadata, + ) -> Result { + let settings = super::offboard::settings::Settings::load(data, metadata)?; + let grab_settings = settings.board; + let ( + controller_settings, + metrics, + movement_vs_stick_angle, + turn_vs_stick_angle, + air_launch, + ) = settings.into_controller_parts(); + Ok(Self { + controller: skate_core::player::offboard::controller::Controller::new( + controller_settings, + metrics, + ), + result: None, + ground: ground_entry::State::default(), + contact: contact_toolkit::ContactPrefix::reset(), + geometry_adjustment: None, + skeleton_state: super::offboard::skeleton_ground::State::load(data)?, + geometry: super::offboard::ground_geometry::State::load(data)?, + movement_vs_stick_angle, + turn_vs_stick_angle, + air_launch, + collision_settings: lifecycle::load_collision_settings(data)?, + grab_settings, + }) + } + + pub(crate) fn reset(&mut self, toolkit: &mut contact_toolkit::Owner) { + //82D30BD0 is a Ground-state reset, not Biped82D7B1C0. + //Keep controller cadence, correction vectors and shared candidate alive. + self.result = None; + self.ground.flags_144_to_150 = [false; 7]; + self.ground.counter_152 = 0; + self.ground.counter_156 = 0; + self.ground.elapsed_160 = 0.; + toolkit.reset_history(); + self.ground = ground_entry::State::default(); + self.contact = contact_toolkit::ContactPrefix::reset(); + self.geometry_adjustment = None; + } + + pub(crate) fn run( + &mut self, + job: skate_core::player::offboard::controller::GroundJob, + ) -> skate_core::player::offboard::controller::GroundResult { + let result = self.controller.step_ground(&job); + self.result = Some(result); + result + } + + pub(crate) fn enter( + &mut self, + input: ground_entry::Input, + current_state: u32, + previous_frame: [[f32; 4]; 4], + toolkit: &mut contact_toolkit::Owner, + ) { + self.reset(toolkit); + self.geometry.reset(); + let placement = self.ground.enter(&input); + self.controller + .place(skate_core::player::offboard::controller::PlacementInput { + frame: placement.frame, + velocity: placement.planar_velocity, + body_position: placement.body_position, + current_state, + previous_state: input.previous_state_2504, + previous_frame, + }); + } +} + +pub(crate) fn enter(_physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + //82D30848 calls GetEffectiveRoot82BE3650 BEFORE Ground placement. + let frame = entry::effective_root( + skater.animated_skeleton.roots.animation_to_world, + p.flags_2476, + ); + let previous_frame = p + .effective_anim_transform_192 + .map(|v| v.map(f32::from_bits)); + skater.biped_ground.enter( + ground_entry::Input { + animation_frame: frame, + processed_velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + processed_flags_2484: p.flags_2484, + processed_flags_2476: p.flags_2476, + requested_angle_2936: skater.animation_input.extra.biped_start_angle, + requested_duration_2896: skater.animation_input.fields.animation_time, + previous_state_2504: p.state_2504, + previous_frame_up_208: previous_frame[1], + //82BDE398..3E0: COM frame15824 + translation48 = Skeleton15872. + body_position_15872: skater.animated_skeleton.board_frames.com_frame[3], + }, + p.state_2508, + previous_frame, + &mut skater.offboard_contact, + ); + //82D30B44: actual state56 ConsiderReset, after placement and before mode4. + services::enter_feet(skater); + //82D30B54 requests the shared skeleton collision controller's mode4. + skater + .ground_lifecycle + .skeleton_controller + .request(4, &mut skater.skeleton_collision)?; + //82D30B5C..B64: state52 grab manager, NOT SkateboardController60. + skater.offboard_grab.enter_reset(); + Ok(()) +} + +pub(crate) fn exit(skater: &mut SkaterRuntime) { + //82D30CD8 refreshes, then30CE4..CF8 clears readiness/history before + //releasing Ground geometry. Candidate and Biped cadence remain alive. + skater.offboard_contact.reset_history(); + //82D30CC0 releases the ground query. It does NOT reset shared Biped + //motion/cadence/correction; BipedAir and subsequent placement consume them. + skater.biped_ground.geometry.reset(); +} + +pub(crate) fn update( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + _controls: &super::PlayerControls, + contact: ground_job::ContactSnapshot, +) -> Result { + let p = skater.player_input.processed; + let fields = skater.animation_input.fields; + let extra = skater.animation_input.extra; + let animation_motion_sq = fields.animation_translation[..3] + .iter() + .map(|value| value * value) + .sum::(); + if p.flags_2488 & 0x0800_0000 != 0 + && skater.biped_ground.controller.state.motion.target_scale_784 < 0.0 + && animation_motion_sq < f32::from_bits(0x3a83_126f) + && !(fields.animation_time > 0.0) + { + // Temporary first-producer trace. Native Ground movement82D7F4BC enters + // the directed branch and82D7F624 divides target distance by AnimTime; + // stock graph publication must make this packet valid before that call. + bevy::log::error!( + "OFFBOARD_DIRECTED_INPUT_INVALID state={}/{} previous={} filtered={} motion_state={:?} flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x} grind_words={:08x?} grind_flags={:02x}/{:02x} offboard_edge_distance={} offboard_obstacle_distance={} anim_time={} anim_translation={:?} anim_velocity={:?} target_scale={} frame={:?} attributes={:?}", + p.state_2508, + p.category_2512, + p.state_2504, + skater.player_input.physical.filtered_state_0, + skater.animation.motion_controller.frame.current, + p.flags_2468, + p.flags_2472, + p.flags_2476, + p.flags_2480, + p.flags_2488, + p.grind_words_2532_2536, + skater.player_input.physical.grinds.flag_318, + skater.player_input.physical.grinds.flag_322, + skater.player_input.physical.off_board.distance_116, + skater.player_input.physical.off_board.scalar_112, + fields.animation_time, + fields.animation_translation, + skater.animated_skeleton.motion.velocity_world, + skater.biped_ground.controller.state.motion.target_scale_784, + skater.biped_ground.ground.frame_80, + skater.animation.attributes.entries(), + ); + } + let frame = skater.biped_ground.ground.frame_80; + let input = skate_core::player::offboard::ground_input::GroundInput { + processed_flags_2472: p.flags_2472, + processed_direct_2684: extra.offboard_magnitude, + processed_direct_2680: extra.offboard_turn, + processed_stick_2692: extra.biped_world_x, + processed_stick_2688: extra.biped_world_z, + processed_scale_2912: fields.magnitude_scale, + processed_scale_2908: fields.turn_scale, + frame_forward_112: [frame[2][0], frame[2][1], frame[2][2]], + }; + let prepared = skate_core::player::offboard::ground_input::calculate( + &input, + &skater.biped_ground.movement_vs_stick_angle, + &skater.biped_ground.turn_vs_stick_angle, + ); + let third_line = p.line_tests_960_1008_1056[2]; + let owner = &mut skater.biped_ground; + let job = ground_job::prepare( + &mut owner.contact, + &mut owner.ground.distance_164, + ground_job::Input { + contact, + frame, + previous_state: p.state_2504, + third_line_position: (third_line.valid != 0) + .then(|| third_line.position.map(f32::from_bits)), + frames_since_teleport: p.frames_since_teleport_2584, + processed_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + processed_velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + controls: prepared, + collision_displacements: skater.collision_extra_errors, + animation_motion: fields.animation_translation, + animation_velocity: skater.animated_skeleton.motion.velocity_world, + requested_duration: fields.animation_time, + requested_phase: fields.cadence_end_percent, + override_duration: fields.animation_physics_blend_seconds, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + }, + |input| Ok::<_, String>(owner.geometry.consume(input)), + )?; + owner.geometry_adjustment = Some(job.geometry); + let result = owner.run(job.job); + if result + .physical_frame + .iter() + .flatten() + .chain(result.animation_frame.iter().flatten()) + .chain(result.surface_frame.iter().flatten()) + .chain(result.velocity.iter()) + .chain(result.position.iter()) + .any(|value| !value.is_finite()) + { + // Temporary first-divergence trace; do not repair or suppress the value. + bevy::log::error!( + "OFFBOARD_GROUND_RESULT_NONFINITE job={:?} result={:?} controller={:?}", + job.job, + result, + owner.controller.state, + ); + } + //82D31CE8..D04 is Ground FeetIK, not Air's timer-reset wrapper. + services::update_feet(skater); + //82D31D14 calls the shared board steering owner with zero input before + //possession. Offboard Ground must still advance its tilt and timers. + skater.ground.steering.update( + 0.0, + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + //82D31D1C..DB0 operates the EXISTING carried-board controller. + ground_board::update_possession(physics, skater); + //82D32338 precedes contact correction and angular unwind. Submit through + //the parent's persistent selector; Ground never selects Air speculatively. + //Current authored scene has no grind edges. Typed absence is accepted by + //every native non-mode1 launch; no fabricated departure vectors. + let launch_input = services::launch_input(skater, None)?; + if let Some(packet) = skater.biped_ground.prepare_air(&launch_input)? { + services::submit_air(physics, skater, packet)?; + } + let owner = &mut skater.biped_ground; + owner.ground.flags_144_to_150[0] = false; + // Sync82D31E64..82D32070: physical contact correction and the Enter + // angular unwind must precede BOTH Skeleton and the next query submission. + let animation_frame = ground_job::sync_frames(&mut owner.ground, owner.contact, result); + let collision = super::input_phase::collision(skater); + //82D32084 passes state1056 (GroundResult208), not processed752. + let centre_of_mass = result.position; + let body_spin = skater.animation_input.extra.physical_body_spin; + let simulation = physics.settings.step.simulation; + let mut owners = super::skeleton_input_runtime::SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + let mut reckoning_update = None; + skater.skeleton_input.update_biped_ground( + &mut skater.skeleton_air, + &mut physics.board, + crate::physics::offboard::skeleton_ground::Input { + world_frame: &animation_frame, + centre_of_mass_1056: centre_of_mass, + }, + &mut skater.biped_ground.skeleton_state, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + simulation, + |update| { + reckoning_update = Some(update); + Ok(()) + }, + )?; + if let Some(update) = reckoning_update { + skater.biped_ground.skeleton_state.finish_reckoning( + update, + &mut physics.riding, + &mut skater.air_reckoning, + &skater.player_input.processed, + body_spin, + ); + } + //82D32088..32108: real grab-spline host executes grab::sync here. + services::sync_grab(skater); + //82D32130..164: surface-frame axes, NOT physical-frame axes. + Ok(contact_toolkit::Input { + position: skater.biped_ground.ground.frame_80[3], + forward: result.surface_frame[2], + up: result.surface_frame[1], + right: result.surface_frame[0], + velocity: result.velocity, + animation_up: animation_frame[1], + animation_right: animation_frame[0], + }) +} + +/// Call immediately AFTER submitting update's toolkit input through the shared +/// owner. The seven real line results become visible at the NEXT PreUpdate. +pub(crate) fn submit_geometry( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let p = &skater.player_input.processed; + let owner = &mut skater.biped_ground; + let result = owner + .result + .ok_or("Ground geometry submission requires completed motion")?; + owner.geometry.submit( + &physics.world, + owner.ground.frame_80, + result.velocity, + skate_core::player::offboard::ground_query::QueryContext { + selection_flags_2948: p.actor_query_2948, + matching_id_2952: p.actor_query_2952 as i32, + }, + p.flags_2488, + ) +} diff --git a/crates/skate-game/src/physics/biped_ground/entry.rs b/crates/skate-game/src/physics/biped_ground/entry.rs new file mode 100644 index 00000000..7be4e4f4 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/entry.rs @@ -0,0 +1,15 @@ +//! Effective animation root82BE3650, called by Ground Enter at82D30848. +type Frame = [[f32; 4]; 4]; + +///The source multiplies every lane of X/Z by -1, not the position or up. +pub(super) fn effective_root(mut frame: Frame, flags_2476: u32) -> Frame { + if flags_2476 & 4 != 0 { + for axis in [0, 2] { + frame[axis] = frame[axis].map(|v| v * -1.); + } + } + frame +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-game/src/physics/biped_ground/entry/tests.rs b/crates/skate-game/src/physics/biped_ground/entry/tests.rs new file mode 100644 index 00000000..83cca227 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/entry/tests.rs @@ -0,0 +1,23 @@ +use super::effective_root; + +const ROOT: [[f32; 4]; 4] = [ + [1., 2., 3., 4.], + [5., 6., 7., 8.], + [9., 10., 11., 12.], + [13., 14., 15., 16.], +]; + +#[test] +fn ordinary_entry_preserves_the_complete_root() { + assert_eq!(effective_root(ROOT, 0), ROOT); + assert_eq!(effective_root(ROOT, !4), ROOT); +} + +#[test] +fn mirrored_entry_flips_only_x_and_z_including_fourth_lanes() { + let result = effective_root(ROOT, 4); + assert_eq!(result[0], [-1., -2., -3., -4.]); + assert_eq!(result[2], [-9., -10., -11., -12.]); + assert_eq!(result[1], ROOT[1]); + assert_eq!(result[3], ROOT[3]); +} diff --git a/crates/skate-game/src/physics/biped_ground/feet.rs b/crates/skate-game/src/physics/biped_ground/feet.rs new file mode 100644 index 00000000..eb0dcc19 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/feet.rs @@ -0,0 +1,71 @@ +//! Ground-only conditioning82D77330 and history82D775D8 around the shared +//! FeetIK evaluator. Air's per-update timer resets do NOT belong here. +use skate_core::{ + animation::foot_ik::state::FootIkState, + player::offboard::{biped_air::recovered::feet as core, board_possession::manager::State}, +}; + +/// The host supplies actual current bone/line/root observations to core::Input. +/// This borrows state56; it never creates or retains another FeetIK owner. +pub(crate) fn update(manager: &mut State, mut input: core::Input, ik: &mut FootIkState) { + //82D77330 only conditions when BOTH incoming line tests are valid. + if input.lines.iter().all(|line| line.valid) { + let d = std::array::from_fn::<_, 2, _>(|i| { + input.lines[i].position[1] - input.world_foot_pairs[i][0][1] + }); + for i in 0..2 { + if d[i].abs() > 0.3 && (d[i] - d[1 - i]).abs() > 0.3 { + input.lines[i].valid = false; + } + } + } + let targets = core::update(manager, &input); + //82D77558 applies IK before Ground correction/history82D775D8. + if input.flags_2484 & 0x8000_0000 == 0 { + for (i, target) in targets.into_iter().enumerate() { + let limb = &mut ik.limbs[i]; + let external = &mut ik.external_targets[i]; + if target.world { + external.world_position = target.position; + limb.external_target_set = true; + } else { + external.animation_position = target.position; + limb.local_target_set = true; + } + limb.target_blend = target.blend; + if let Some(normal) = target.normal { + core::set_normal(external, normal); + } + } + } + update_history(manager); +} + +fn update_history(s: &mut State) { + //Both invalid deliberately leaves316 unchanged;312 still increments. + let mut both_bad = true; + if s.hands.iter().any(|foot| foot.flag_84) { + s.words_308_to_316[2] = s.words_308_to_316[2].wrapping_add(1); + for foot in &s.hands { + let height = foot.vectors_0_to_64[1][1] - foot.vectors_0_to_64[3][1]; + if foot.flag_84 && !(height > 0.18) { + s.words_308_to_316[2] = 0; + } + let bad = !foot.flag_84 || height > 1. || foot.vectors_0_to_64[4][1] < 0.6; + both_bad &= bad; + } + } + if both_bad { + s.words_308_to_316[1] = s.words_308_to_316[1].wrapping_add(1); + if s.words_308_to_316[1] as i32 > 20 || s.words_308_to_316[2] as i32 > 40 { + s.flags_304_to_307[1] = true; + } + } else { + s.flags_304_to_307[1] = false; + s.words_308_to_316[1] = 0; + } + s.words_308_to_316[0] = 0; + s.flags_304_to_307[2] = false; + s.vectors_224_to_272[0] = [0.; 4]; + s.vectors_224_to_272[1] = [0.; 4]; +} diff --git a/crates/skate-game/src/physics/biped_ground/grab.rs b/crates/skate-game/src/physics/biped_ground/grab.rs new file mode 100644 index 00000000..88c12e50 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/grab.rs @@ -0,0 +1,82 @@ +//! Concrete Ground grab decision82D324B0. Query readiness belongs to ONE owner. +use super::grab_runtime::Owner; +use skate_core::player::offboard::{ + grab_scene::{self, Descriptor, Query}, + ground_entry::{Frame, State, Vector}, + ground_query::QueryContext, + ground_sync::{BoardLimits, BoardSettings, board_bounds}, +}; +pub(crate) struct Input { + pub frame: Frame, + pub position: Vector, + pub bone: Vector, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub elapsed: f32, + pub timer_2852: f32, + pub context: QueryContext, +} +pub(crate) fn sync(state: &mut State, owner: &mut Owner, settings: BoardSettings, p: Input) { + if p.flags_2484 & 0x20000000 != 0 || p.flags_2480 & 0x40000 != 0 || p.timer_2852 > 0. { + return; + } + if p.flags_2476 & 0x400000 == 0 { + owner.invalidate(); + return; + } + if state.flags_144_to_150[4] { + if !(p.elapsed <= 0.5) { + state.flags_144_to_150[4] = false; + } + return; + } + let bounds = board_bounds(p.frame, settings.offset_32, settings.extent_16); + if owner.flags_12836 & 0x40 != 0 { + if let Some(candidate) = owner.best(p.position) { + let inner = board_bounds( + p.frame, + settings.offset_32, + settings.extent_0.map(|v| v * f32::from_bits(0x3f733333)), + ); + state.flags_144_to_150[0] = grab_scene::qualify( + &candidate, + p.bone, + inner, + limits(settings.margin_444, settings.angle_452, settings.angle_436), + ); + if state.flags_144_to_150[0] { + let key = candidate.descriptor(); + state.counter_152 = key.kind; + state.counter_156 = key.id; + } + } + } + //Query always submits, even when the previous result was rejected. + owner.query(Query { + position: p.position, + sort_position: p.position, + bounds, + limits: limits(settings.margin_444, settings.angle_456, settings.angle_440), + mode: 4, + capacity: 5, + context: p.context, + }); + if state.flags_144_to_150[0] { + //82585DD8 descriptor initialization followed by827602E0 actual data request. + owner.request_primary(Descriptor { + kind: state.counter_152, + id: state.counter_156, + }); + } else { + owner.request_interactable(p.frame, p.context); + } +} +fn limits(margin: f32, a: f32, b: f32) -> BoardLimits { + let radians = f32::from_bits(0x3c8efa35); + BoardLimits { + margin, + angle_a: a * radians, + angle_b: b * radians, + } +} diff --git a/crates/skate-game/src/physics/biped_ground/grab_runtime.rs b/crates/skate-game/src/physics/biped_ground/grab_runtime.rs new file mode 100644 index 00000000..c75648f8 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/grab_runtime.rs @@ -0,0 +1,220 @@ +//! PlayerGrabSpline82D73FC0/74270/740F8/749D0, distinct from possession. +//! Scene execution is concrete; retained results become visible only at Sync. +mod selection; +#[cfg(test)] +mod tests; +use crate::physics::offboard::grab_scene::Scene; +use skate_core::player::offboard::{ + grab_scene::{Descriptor, Hit, Line, Query, Record, closest_point}, + ground_entry::{Frame, Vector}, + ground_query::QueryContext, +}; + +#[derive(Default)] +pub(crate) struct Owner { + queries: Vec, + query_result: Option>, + pending: Vec, + validated: Vec, + validation: Option>>, + requests: [Option; 2], + data: [Option; 2], + data_ready: [bool; 2], + interactable_request: Option<[Line; 5]>, + interactable_result: Option>, + pub(crate) interactable_latched: bool, + pub(crate) flags_12836: u8, + query_position: Vector, + validation_position: Vector, +} + +///None for a record means clear its publication bit, retaining previous payload. +///Nested object Option distinguishes no completion from completed no-object. +pub(crate) struct Publication { + pub records: [Option; 2], + pub object: Option>, +} + +impl Owner { + pub(crate) fn query(&mut self, query: Query) { + self.query_position = query.position; + self.query_result = None; //8275FA78 clears ready before enqueue. + self.queries.push(query); + } + + pub(crate) fn request_primary(&mut self, descriptor: Descriptor) { + self.requests[0] = Some(descriptor); + self.data[0] = None; + self.data_ready[0] = false; + } + + ///8275FD00 captures five actual forward lines; query execution is deferred. + pub(crate) fn request_interactable(&mut self, frame: Frame, context: QueryContext) { + let lines = std::array::from_fn(|i| { + let mut start = frame[3]; + start[1] += (i as f32) * f32::from_bits(0x3eb851eb); + let end = std::array::from_fn(|axis| frame[2][axis].mul_add(2., start[axis])); + Line { + start, + end, + radius: 0.2, + group: context.matching_id_2952, + reject_flags: 0, + source_pool_mask: 2, + selection_flags: context.selection_flags_2948, + } + }); + //The native manager keeps only its first outstanding interactable batch. + if self.interactable_request.is_none() { + self.interactable_request = Some(lines); + } + self.interactable_result = None; + } + + ///82760060: bounded queries, requested data, then interactable results. + pub(crate) fn execute_queries(&mut self, scene: &Scene<'_>) -> Result<(), String> { + for query in &self.queries { + self.query_result = Some(scene.query(query)?); + } + self.queries.clear(); + for i in 0..2 { + if let Some(descriptor) = self.requests[i].take() { + self.data[i] = scene.resolve(descriptor)?; + self.data_ready[i] = true; + } + } + if let Some(lines) = self.interactable_request.take() { + let mut nearest = f32::MAX; + let mut object = None; + for line in lines { + if let Some(hit) = scene.line(line)? { + if hit.fraction != 0. && hit.fraction < nearest && hit.assembly.is_some() { + if let Some(id) = scene.eligible_object(hit) { + nearest = hit.fraction; + object = Some(id); + } + } + } + } + self.interactable_result = Some(object); + } + Ok(()) + } + + ///82D74270: old validation is consumed BEFORE new query results spawn lines. + pub(crate) fn sync(&mut self, scene: &Scene<'_>, context: QueryContext) -> Result<(), String> { + self.flags_12836 &= !0x40; + if let Some(hits) = self.validation.take() { + if self.flags_12836 & 0x80 != 0 { + validate(&mut self.pending, &hits); + //82D74888 refreshes geometry by descriptor from newest results. + if let Some(latest) = &self.query_result { + for old in &mut self.pending { + if let Some(new) = latest.iter().find(|r| { + r.descriptor().kind == old.descriptor().kind + && r.descriptor().id == old.descriptor().id + }) { + *old = new.clone(); + } + } + } + } + self.validated = self.pending.clone(); + self.flags_12836 = ((self.flags_12836 >> 1) & 0x40) | (self.flags_12836 & 0x3f); + self.validation_position = [0.; 4]; + } + if let Some(results) = self.query_result.take() { + self.pending = results.into_iter().take(5).collect(); + self.validation_position = self.query_position; + self.query_position = [0.; 4]; + self.flags_12836 |= 0x80; + let mut hits = Vec::with_capacity(self.pending.len() * 3); + for record in &self.pending { + let mut end = closest_point(self.validation_position, record.endpoints()); + let mut start = self.validation_position; + let mut height = start[1] - f32::from_bits(0x3ecccccd); + for _ in 0..3 { + start[1] = height; + end[1] = height; + hits.push(scene.line(Line { + start, + end, + radius: 0.2, + group: context.matching_id_2952, + reject_flags: 0, + source_pool_mask: 7, + selection_flags: context.selection_flags_2948, + })?); + height += f32::from_bits(0x3ecccccd); + } + } + //Even an empty real query creates a completed empty batch. + self.validation = Some(hits); + } + Ok(()) + } + + pub(crate) fn best(&self, position: Vector) -> Option { + selection::best(&self.validated, position) + } + + ///82D749D0 cancels spline queries/validation and clears only native readiness. + ///Scene lines have already completed; dropping them does not publish results. + pub(crate) fn invalidate(&mut self) { + self.queries.clear(); + self.query_result = None; + self.validation = None; + self.data_ready = [false; 2]; + self.flags_12836 &= 0x3f; + } + + ///GroundEnter82D30B64 additionally clears interactable publication readiness. + pub(crate) fn enter_reset(&mut self) { + self.invalidate(); + self.interactable_result = None; + } + + ///82D740F8 consumes completion, not retained candidate existence. + pub(crate) fn publish(&mut self) -> Publication { + let records = std::array::from_fn(|i| { + let record = if self.data_ready[i] { + self.data[i].as_ref().filter(|r| r.valid()).cloned() + } else { + None + }; + self.data_ready[i] = false; + record + }); + let object = self.interactable_result.take(); + if let Some(result) = object { + self.interactable_latched = result.is_some(); + } + Publication { records, object } + } +} + +///82D74740, raw747B8/747F4/7481C..74874. S2 has an extra outer hit-index +///increment at82DCC0FC; S3 does not. Do not group hits into chunks of three. +fn validate(records: &mut Vec, hits: &[Option]) { + let mut record_index = 0; + let mut hit_index = 0; + while record_index < records.len() { + let assembly = records[record_index].assembly(); + let mut obstructed = false; + for _ in 0..3 { + if let Some(hit) = &hits[hit_index] { + obstructed = + hit.assembly.is_none() || (assembly.is_some() && hit.assembly != assembly); + } + hit_index += 1; + if obstructed { + break; + } + } + if obstructed { + records.swap_remove(record_index); + } else { + record_index += 1; + } + } +} diff --git a/crates/skate-game/src/physics/biped_ground/grab_runtime/selection.rs b/crates/skate-game/src/physics/biped_ground/grab_runtime/selection.rs new file mode 100644 index 00000000..8bdd55be --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/grab_runtime/selection.rs @@ -0,0 +1,5 @@ +//! Ground mode0 consumer of the original core82D4D150 implementation. +use skate_core::player::offboard::grab_scene::{Record, best_spline}; +pub(super) fn best(records: &[Record], position: [f32; 4]) -> Option { + best_spline(records, position) +} diff --git a/crates/skate-game/src/physics/biped_ground/grab_runtime/tests.rs b/crates/skate-game/src/physics/biped_ground/grab_runtime/tests.rs new file mode 100644 index 00000000..3c24bdfe --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/grab_runtime/tests.rs @@ -0,0 +1,98 @@ +use super::*; +use skate_core::player::offboard::grab_scene::Geometry; +use std::sync::Arc; +fn record(id: u32, y: f32, z: f32) -> Record { + let a = [-1., y, z, 0.]; + let b = [1., y, z, 0.]; + let geometry = Arc::new(Geometry { + id, + points: vec![a, b], + approach_vectors: vec![], + word_60: 0, + }); + let mut r = Record([0; 72], geometry); + r.0[47] = 2; + r.0[48] = id; + r.0[49] = id; + r.0[52] = id; + r.set_vector(64, a); + r.set_vector(80, b); + r +} +#[test] +fn s3_validation_reprocesses_swap_with_next_hit_not_next_triplet() { + let mut records = vec![record(1, 0., 1.), record(2, 0., 1.), record(3, 0., 1.)]; + //First null-assembly obstruction removes1. Next hit belongs to3: if the + //implementation aligns to three hits or keeps original order, it rejects3. + let hits = [ + Some(Hit { + fraction: 0.5, + assembly: None, + }), + Some(Hit { + fraction: 0.5, + assembly: Some(3), + }), + None, + None, + Some(Hit { + fraction: 0.5, + assembly: Some(2), + }), + None, + None, + None, + None, + ]; + validate(&mut records, &hits); + assert_eq!( + records + .iter() + .map(|r| r.descriptor().id) + .collect::>(), + vec![3, 2] + ); +} +#[test] +fn nearest_ineligible_suppresses_farther_eligible() { + let near = record(1, 0.6, 0.1); + let far = record(2, 0., 2.); + assert!(selection::best(&[far.clone()], [0.; 4]).is_some()); + assert!(selection::best(&[far, near], [0.; 4]).is_none()); +} +#[test] +fn both_height_boundaries_are_exclusive() { + for height in [-0.6, 0.6] { + assert!(selection::best(&[record(1, height, 0.2)], [0.; 4]).is_none()); + } +} +#[test] +fn invalidation_preserves_retained_candidates_and_interactable_completion() { + let mut owner = Owner::default(); + owner.validated.push(record(1, 0., 1.)); + owner.flags_12836 = 0xff; + owner.interactable_result = Some(Some(44)); + owner.data_ready = [true, true]; + owner.invalidate(); + assert_eq!(owner.flags_12836, 0x3f); + assert_eq!(owner.validated.len(), 1); + assert_eq!(owner.interactable_result, Some(Some(44))); + assert_eq!(owner.data_ready, [false, false]); + owner.enter_reset(); + assert_eq!(owner.interactable_result, None); +} +#[test] +fn publication_consumes_ready_not_retained_data() { + let mut owner = Owner::default(); + owner.data[0] = Some(record(1, 0., 1.)); + owner.data_ready[0] = true; + owner.interactable_result = Some(Some(55)); + let first = owner.publish(); + assert!(first.records[0].is_some()); + assert_eq!(first.object, Some(Some(55))); + assert!(owner.interactable_latched); + let second = owner.publish(); + assert!(second.records[0].is_none()); + assert_eq!(second.object, None); + assert!(owner.data[0].is_some()); +} diff --git a/crates/skate-game/src/physics/biped_ground/ground_board.rs b/crates/skate-game/src/physics/biped_ground/ground_board.rs new file mode 100644 index 00000000..bb46d4f4 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/ground_board.rs @@ -0,0 +1,47 @@ +//! Ground carried-board Update82D31D1C..DB0. State60 is SkateboardController; +//! state52 in Sync82D324B0 is a DIFFERENT grab-spline manager. +use super::{GamePhysics, SkaterRuntime}; + +/// Executes original Stop/Hold effects on the existing possession/solver owner. +/// Does not run its common per-frame Update a second time. +pub(crate) fn update_possession(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + let flags = skater.player_input.processed.flags_2484; + let state = skater.skateboard_controller.fields.state_448; + let target = if flags & 1 != 0 { + if matches!(state, 2 | 3 | 4) { + return; + } + 5 + } else { + if state != 5 { + return; + } + 1 + }; + skater.skateboard_controller.fields.word_444 = 0; + if state == target { + return; + } + let observation = super::super::offboard::board_manager::runtime::observe(physics, skater); + let mut effects = skater.board_possession_live.effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ); + if target == 5 { + skater.board_possession.stop( + &mut skater.skateboard_controller.fields, + &observation, + &mut effects, + ); + } else { + skater.board_possession.hold( + &mut skater.skateboard_controller.fields, + &observation, + &mut effects, + ); + } + //82D31D80/DB0 assigns state only AFTER the physical action. + skater.skateboard_controller.fields.state_448 = target; + skater.board_possession_live.publish_volumes(physics); +} diff --git a/crates/skate-game/src/physics/biped_ground/lifecycle.rs b/crates/skate-game/src/physics/biped_ground/lifecycle.rs new file mode 100644 index 00000000..4e7e33fd --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/lifecycle.rs @@ -0,0 +1,115 @@ +//! Ground Post82D32AA0 and complete typed Fill82D32D38/82D785F8. +//! Parent publishes every returned field through the shared output records. +use super::SkaterRuntime; +use skate_core::player::offboard::{ + board_possession::manager::State as FeetState, + ground_lifecycle::{self as core, CollisionInput, CollisionSettings, Publication}, +}; +use skate_data::collections::Collections; + +/// Apply these AFTER the shared state template/reset, not before it. +#[must_use = "Ground State36/40/86 must survive the shared output reset"] +pub(crate) struct StatePublication { + pub word_36: u32, + pub word_40: u32, + pub flag_86: bool, +} + +/// Apply every represented record field and return state-template writes for +/// the parent's common publication reset/reconstruction. +/// The actual shared record type is PhysicalPlayerInput, not PhysicsOutputFields. +pub(crate) fn publish_fields( + p: Publication, + physical: &mut skate_core::player::input_phase::PhysicalPlayerInput, +) -> StatePublication { + let o = &mut physical.off_board; + o.flag_304 = u8::from(p.offboard_flag_304); + o.kind_88 = p.offboard_kind_88; + o.scalar_112 = p.offboard_scalar_112; + o.flag_329 = u8::from(p.offboard_flag_329); + o.flag_330 = u8::from(p.offboard_flag_330); + o.distance_116 = p.offboard_distance_116; + o.flag_334 = u8::from(p.offboard_flag_334); + o.scalar_32 = p.offboard_scalar_32; + o.flag_328 = u8::from(p.offboard_flag_328); + o.flags_306_307 = p.offboard_hand_flags_306_307.map(u8::from); + physical.reckoning.vector_144 = p.animation_vector_144.map(f32::to_bits); + physical.reckoning.flag_164 = u8::from(p.animation_flag_164); + physical.state.counter_36 = p.physics_counter_36; + physical.state.skitch_value_40 = p.physics_counter_40; + StatePublication { + word_36: p.physics_counter_36, + word_40: p.physics_counter_40, + flag_86: p.physics_flag_86, + } +} + +pub(super) fn load_collision_settings(data: &Collections) -> Result { + let value = |name| data.float("physics_wipeout", "default", name); + Ok(CollisionSettings { + vehicle_scalar: value("Wipeout_OB_VehicleScalar")?, + vehicle_contact: value("Wipeout_OB_VehicleContact")?, + maximum_displacement: value("Wipeout_OB_SkeletonMaxDisp")?, + maximum_arm_contact: value("Wipeout_OB_SkeletonMaxContactArms")?, + maximum_body_contact: value("Wipeout_OB_SkeletonMaxContact")?, + minimum_speed: value("Wipeout_OB_MinSpeed")?, + maximum_squash: value("Wipeout_OB_MaxSquash")?, + special_scalar: value("Hash_472174920C68FBE3")?, + }) +} + +/// Called AFTER skeleton feedback, before shared FootIK Post consumes requests. +/// `collision` borrows the completed canonical Frame and real Skeleton16336 / +/// SkeletonCollision4072/4056; there is no pre-solve/default observation path. +pub(crate) fn post(skater: &mut SkaterRuntime, collision: CollisionInput<'_>) { + let p = &skater.player_input.processed; + let owner = &mut skater.biped_ground; + core::post_physics( + &mut owner.ground, + &mut skater.wipeout.state, + &owner.collision_settings, + &core::PostInput { + collision, + processed_flags_2484: p.flags_2484, + processed_velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + skeleton_displacement_16288: skater.collision_extra_errors[0], + skeleton_displacement_16304: skater.collision_extra_errors[1], + ground_kind_356: owner.contact.kind_164, + }, + ); +} + +/// No output is silently dropped: the full packet includes State36/40/86, +/// OffBoard88/112/116/304/306/307/328/329/330/334, and Reckoning144/164. +/// Borrow the ONE state56 FeetIK owner (its historical type calls feet hands). +pub(crate) fn fill(skater: &SkaterRuntime, feet: &FeetState) -> Result { + let owner = &skater.biped_ground; + let motion = owner + .result + .ok_or("Ground Fill requires completed Ground motion")?; + let geometry = owner + .geometry_adjustment + .ok_or("Ground Fill requires this tick's real geometry consumption")?; + let position = geometry.frame_768.position; + Ok(core::publish( + &owner.ground, + &core::PublicationInput { + ground_flags_752_to_754: [geometry.state_752, geometry.state_753, geometry.state_754], + contact_flags_368: owner.contact.flags_176, + contact_position_192: owner.contact.position, + query_position_816: [position.x, position.y, position.z, 0.], + processed_flags_2476: skater.player_input.processed.flags_2476, + ground_kind_356: owner.contact.kind_164, + ground_scalar_360: owner.contact.distance_168, + motion_vector_1040: motion.velocity, + motion_up_864: motion.physical_frame[1], + hand_flags: feet.hands.map(|foot| { + [ + foot.flags_104_to_107[1], + foot.flags_104_to_107[2], + foot.flags_104_to_107[3], + ] + }), + }, + )) +} diff --git a/crates/skate-game/src/physics/biped_ground/services.rs b/crates/skate-game/src/physics/biped_ground/services.rs new file mode 100644 index 00000000..da522598 --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/services.rs @@ -0,0 +1,158 @@ +//! Concrete, stateless Ground bindings to the single SkaterRuntime owners. +//! Grab-spline queries/actions are a separate missing host, not board possession. +use super::{GamePhysics, SkaterRuntime, sync::LaunchInput}; +use skate_core::{ + animation::foot_ik::status::Mode, + physics::skeleton_animation_record::compose_affine, + player::offboard::{air_launch, air_selector, biped_air::recovered::feet}, +}; + +///82D770D8; S2 OffBoardFeetIK::ConsiderReset82DCD110 corroborates the owner. +///Do not reset targets/history merely because the board has been released. +pub(crate) fn enter_feet(skater: &mut SkaterRuntime) { + let p = &skater.player_input.processed; + if !matches!(p.state_2504, 500 | 501 | 502) { + skater.offboard_feet.reset(); + skater.foot_ik.state.enable_feet(false); + for limb in &mut skater.foot_ik.state.limbs { + limb.board_blend = 0.; + limb.external_blend = 0.; + limb.mode = Mode::Disabled; + } + } + skater.offboard_feet.flags_304_to_307[0] = p.state_2508 == 502; +} + +///82D773F8 reads real line tests and82BE3220 world animation-record bones. +///Same input extraction as air_feet; neither its timer reset nor Air update +///is executed. Ground conditioning/history belong to super::feet::update. +pub(crate) fn feet_input(skater: &SkaterRuntime) -> feet::Input { + let p = &skater.player_input.processed; + let animation = &skater.animated_skeleton; + let root = animation.roots.animation_to_world; + let effective = super::entry::effective_root(root, p.flags_2476); + feet::Input { + lines: std::array::from_fn(|i| { + let line = p.line_tests_960_1008_1056[i]; + feet::Line { + position: line.position.map(f32::from_bits), + normal: line.normal.map(f32::from_bits), + surface: line.surface, + valid: line.valid != 0, + } + }), + root, + inverse_root: animation.roots.world_to_animation, + effective_root: effective, + local_foot_pairs: [15, 19] + .map(|i| [animation.record.pose[i][3], animation.record.pose[i + 1][3]]), + world_foot_pairs: [15, 19].map(|i| { + [ + compose_affine(&root, &animation.record.pose[i])[3], + compose_affine(&root, &animation.record.pose[i + 1])[3], + ] + }), + position: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + state: p.state_2508, + } +} + +pub(crate) fn update_feet(skater: &mut SkaterRuntime) { + let input = feet_input(skater); + super::feet::update(&mut skater.offboard_feet, input, &mut skater.foot_ik.state); +} + +pub(crate) fn launch_input( + skater: &SkaterRuntime, + geometry: Option, +) -> Result { + let p = &skater.player_input.processed; + let board = skater + .player_input + .toolkit + .as_ref() + .ok_or("Ground launch requires the completed BoardToolkit")?; + Ok(LaunchInput { + processed: air_launch::Processed { + board_position_112: board.deck[3], + forward_224: p.effective_anim_transform_192[2].map(f32::from_bits), + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + velocity_912: p.vectors_880_896_912_928_944[2].map(f32::from_bits), + departure_geometry: geometry, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + previous_state_2504: p.state_2504, + current_state_2508: p.state_2508, + current_category_2512: p.category_2512, + previous_category_2516: p.category_2516, + raw_x_2692: skater.animation_input.extra.biped_world_x, + raw_z_2688: skater.animation_input.extra.biped_world_z, + }, + elapsed_2664: p.state_timer_2664, + skeleton_point_10960: skater.animated_skeleton.record.centre_of_mass, + }) +} + +///82D32088..32108, after Skeleton has published its real processed fields. +pub(crate) fn sync_grab(skater: &mut SkaterRuntime) { + let p = &skater.player_input.processed; + let bone = compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &skater.animated_skeleton.record.pose[23], + )[3]; + let input = super::grab::Input { + frame: p + .effective_anim_transform_192 + .map(|v| v.map(f32::from_bits)), + position: p.vectors_544_560_592_608[2].map(f32::from_bits), + bone, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + elapsed: p.state_timer_2664, + timer_2852: p.secondary_ground_timer_2852, + context: skate_core::player::offboard::ground_query::QueryContext { + selection_flags_2948: p.actor_query_2948, + matching_id_2952: p.actor_query_2952 as i32, + }, + }; + super::grab::sync( + &mut skater.biped_ground.ground, + &mut skater.offboard_grab, + skater.biped_ground.grab_settings, + input, + ); +} + +///82D6CA58 copies the actual gravity vector and submits; consumption remains +///at the shared selector's native phase, not an invented Ground frame delay. +pub(crate) fn submit_air( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + packet: air_launch::Packet, +) -> Result<(), String> { + let p = &skater.player_input.processed; + let gravity = physics.settings.step.simulation.gravity_acceleration; + let context = air_selector::Context { + selection_flags_2948: p.actor_query_2948, + matching_group_2952: p.actor_query_2952 as i32, + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + forward_224: p.effective_anim_transform_192[2].map(f32::from_bits), + }; + skater + .offboard_air_selector + .launch( + &physics.world, + packet, + [gravity.x, gravity.y, gravity.z, 0.], + context, + ) + .map_err(str::to_owned) +} diff --git a/crates/skate-game/src/physics/biped_ground/sync.rs b/crates/skate-game/src/physics/biped_ground/sync.rs new file mode 100644 index 00000000..6d8c7f4d --- /dev/null +++ b/crates/skate-game/src/physics/biped_ground/sync.rs @@ -0,0 +1,52 @@ +//! Ground-owned Sync stages. The caller executes returned launch on its ONE +//! persistent AirSelector before frame correction; no speculative transition. +use super::Owner; +use skate_core::player::offboard::{air_launch, ground_entry::Vector}; + +pub(crate) struct LaunchInput { + pub processed: air_launch::Processed, + pub elapsed_2664: f32, + pub skeleton_point_10960: Vector, +} +impl Owner { + ///82D32338. This writes state146 on every call, including the no-launch path. + pub(crate) fn prepare_air( + &mut self, + input: &LaunchInput, + ) -> Result, String> { + let motion = self + .result + .ok_or("Ground Sync requires completed Ground job")?; + let p = &input.processed; + let flags = self.contact.flags_176; + let mut launch = flags & 1 == 0 && input.elapsed_2664 > f32::from_bits(0x3d4ccccd); + launch |= motion.alternate; + if flags & 8 == 0 && p.flags_2480 & 0x20180 == 0 && !self.ground.flags_144_to_150[6] { + launch |= p.flags_2476 & 0x80000 != 0; + } + self.ground.flags_144_to_150[2] = launch; + if !launch { + return Ok(None); + } + //104 is uninitialized by82D2DB98 and overwritten on every produce path. + let mut packet = air_launch::Packet::initialized(0.); + air_launch::produce( + &mut packet, + &self.controller.state, + &self.controller.settings.movement_velocity.turn_vs_speed, + self.air_launch, + p, + false, + ) + .map_err(str::to_owned)?; + let frame = motion.animation_frame; + let point = input.skeleton_point_10960; + packet.position_32 = std::array::from_fn(|i| { + frame[2][i].mul_add( + point[2], + frame[1][i].mul_add(point[1], frame[0][i].mul_add(point[0], frame[3][i])), + ) + }); + Ok(Some(packet)) + } +} diff --git a/crates/skate-game/src/physics/boneless.rs b/crates/skate-game/src/physics/boneless.rs index c73deb03..fd79aea4 100644 --- a/crates/skate-game/src/physics/boneless.rs +++ b/crates/skate-game/src/physics/boneless.rs @@ -98,6 +98,6 @@ fn launch(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), S info.player_jumped = true; let input = air_phase::selector_input(physics, skater)?; skater.trajectory.launch(info, input, &physics.world)?; - skater.trajectory.update(input, &physics.world)?; + skater.trajectory.update(input, &physics.world, crate::physics::air_trajectory::GrindContext::from_processed(&skater.player_input.processed, crate::physics::solve::deck_frame(&physics.board)[3]))?; Ok(()) } diff --git a/crates/skate-game/src/physics/camera_output.rs b/crates/skate-game/src/physics/camera_output.rs index ce3b1884..1b186bbd 100644 --- a/crates/skate-game/src/physics/camera_output.rs +++ b/crates/skate-game/src/physics/camera_output.rs @@ -2,7 +2,7 @@ //! Reset82DE53F0 runs before the actual producers82DB6EC0/82D3A388. use super::{GamePhysics, SkaterRuntime}; use crate::camera::{ - CameraAirOutput, CameraAnimationOutput, CameraEventsOutput, + CameraAirOutput, CameraAnimationOutput, CameraEventsOutput, CameraGrindOutput, CameraOffboardOutput, CameraPreferences, CameraPublicationInputs, CameraStateOutput, }; use skate_core::{ @@ -15,26 +15,31 @@ use skate_core::{ /// The user's selected normal High camera is graph type1. This custom world /// has no road/ledge/camera-volume annotations or other moving actors. pub(crate) fn advance( - physics: &mut GamePhysics, + physics: &GamePhysics, skater: &SkaterRuntime, feedback: &PhysicalFeedback, camera: &mut crate::camera::CameraRuntime, ) -> Result<(), String> { - let output = physics.exchange.output() + let output = physics + .exchange + .output() .ok_or("Camera requires the completed physical output snapshot")?; - let completed_tick = physics.ticks.checked_sub(1) - .ok_or("Camera received output before the first completed tick")?; + let completed_tick = physics + .ticks + .checked_sub(1) + .ok_or("Camera received physical output before the first completed tick")?; if output.tick != completed_tick || output.state != skater.player_state.current() { - return Err(format!("Camera received stale physical output: tick={}, completed={completed_tick}", output.tick)); + return Err(format!( + "Camera received stale physical output: output_tick={}, completed_tick={}, output_state={:?}, selected_state={:?}", + output.tick, + completed_tick, + output.state, + skater.player_state.current(), + )); } - //Once per physical publication, preserving conditioner history across - //direct grind-type changes and invalidating it on the first non-grind tick. - physics.grind.advance_camera( - skater.player_input.processed.vectors_400_416[0].map(f32::from_bits), - )?; let inputs = publish( - physics, skater, + output, feedback, skater.centre_of_mass_output, CameraPreferences { @@ -48,6 +53,7 @@ pub(crate) fn advance( // SkaterAnim vtable8231E170+28=82B97140: full15180 bit30. u8::from(skater.animation.stance().1), 1, // Stable host player identity replaces the original actor pointer. + output.tick, )?; let snapshot = crate::camera::publish_camera_subject(physics, skater, &inputs)?; let environment = crate::camera::CameraGraphEnvironment { @@ -83,24 +89,22 @@ impl MovingObstacleProvider for StaticWorld { /// published, before clearing AnimationControlOutput's per-frame intent bits. ///Selected state owners publish their output before this common consumer. pub(crate) fn publish( - physics: &GamePhysics, skater: &SkaterRuntime, + output: &skate_core::physics::phase::PhysicalOutputSnapshot, feedback: &PhysicalFeedback, com: CentreOfMassOutput, preferences: CameraPreferences, skater_animation_stance: u8, context: u32, + tick: u64, ) -> Result { let p = &skater.player_input.processed; let physical = &skater.player_input.physical; let fields = &skater.animation_input.fields; let intents = skater.animation_input.output.flags; let packet = &skater.animation.packet; - let output = physics.exchange.output() - .ok_or("Camera requires the completed physical output snapshot")?; - let normal = output.ground_normal; Ok(CameraPublicationInputs { - tick: output.tick, + tick, state: CameraStateOutput { height_32: physical.state.surface_height_32, physically_pushing_55: bit(p.flags_2468, 25), @@ -134,34 +138,19 @@ pub(crate) fn publish( apex_time_196: physical.air.time_to_apex_196, flag_440: bit(p.flags_2468, 22), }, - //Offboard82DE4010 has a distinct board-present327=1. None of that - //record's alternate trajectory fields becomes valid while riding. - offboard: CameraOffboardOutput { - duration_92: 0.0, - time_152: 0.0, - apex_time_156: 0.0, - launch_normal_160: [0.0; 4], - launch_position_176: [0.0; 4], - landing_normal_192: [0.0; 4], - landing_position_208: [0.0; 4], - heading_224: [0.0; 4], - apex_240: [0.0; 4], - //82DB76E0 overwrites the Ground Fill's earlier304 publication. - object_held_304: bit(p.flags_2480, 19), - hurdle_317: physical.off_board.hippy_hurdling_317, - use_trajectory_331: 0, - dropping_in_334: 0, + offboard: offboard_output(&physical.off_board), + grinds: CameraGrindOutput { + direction_0: physical.grinds.direction_0.map(f32::from_bits), + camera_target_96: physical.grinds.camera_target_96.map(f32::from_bits), + grinding_316: physical.grinds.grinding_316, }, - //Shared grind Fill82D40EA0 publishes the directed rail tangent; - //conditioner82DF0640 supplies the continuous contact anchor. - grinds: physics.grind.camera_output(), events: CameraEventsOutput { intent_51: bit(intents, 28), preparing_52: bit(intents, 27), dropping_in_63: bit(intents, 22), trick_125: bit(p.flags_2480, 11), - //Ground322 resets in82DE3728. Air/trick states own active writes. - hippy_jump_322: 0, + //Completed State503 Fill82D4E0A8, consumed as Ground322 by the camera. + hippy_jump_322: physical.ground.hippy_jumping_322, //Scoring2 reset82DE4468; HoM duration stays inactive in this host. broken_bone_duration_200: 0.0, //AirCollector82DA78D0 publishes the world conditioner capability mask. @@ -169,14 +158,24 @@ pub(crate) fn publish( }, damped_com_80: com.position, //82C03304..3340 copies actual BoardBody80 into Ground80 and96. - ground_up_80: [normal.x, normal.y, normal.z, 0.0], + ground_up_80: [ + output.ground_normal.x, + output.ground_normal.y, + output.ground_normal.z, + 0.0, + ], //Ground288 resets82DE3728; selected ordinary Ground Fill leaves it. ground_scalar_288: 0.0, look_552_556: [ skater.animation_input.extra.look_x, skater.animation_input.extra.look_y, ], - collision_look_target_64: [output.predicted_position.x, output.predicted_position.y, output.predicted_position.z, 0.0], + collision_look_target_64: [ + output.predicted_position.x, + output.predicted_position.y, + output.predicted_position.z, + 0.0, + ], preferences, context, }) @@ -186,6 +185,32 @@ fn bit(value: u32, shift: u32) -> u8 { ((value >> shift) & 1) as u8 } +///82DF6A68..6B8C copies the distinct OffBoard trajectory when byte331 is set. +///Keep the owner's actual availability byte: neither state category nor the +///existence of this packet establishes that a trajectory has been produced. +fn offboard_output( + output: &skate_core::player::input_phase::OffBoardOutputFields, +) -> CameraOffboardOutput { + CameraOffboardOutput { + duration_92: output.scalar_92, + time_152: output.scalar_152, + apex_time_156: output.scalar_156, + //82DF6B74..6B8C supplies OffBoard160 to setter52 at82DF6CA0. + launch_normal_160: output.vector_160.map(f32::from_bits), + launch_position_176: output.vector_176.map(f32::from_bits), + landing_normal_192: output.vector_192.map(f32::from_bits), + landing_position_208: output.vector_208.map(f32::from_bits), + heading_224: output.vector_224.map(f32::from_bits), + apex_240: output.vector_240.map(f32::from_bits), + //82DF74E4 reads308; the camera packet's historical name is misleading. + object_held_304: output.flag_308, + hurdle_317: output.hippy_hurdling_317, + use_trajectory_331: output.trajectory_valid_331, + //82DF7474..7490 combines this byte with the dropping-in event. + dropping_in_334: output.flag_334, + } +} + #[cfg(test)] -#[path = "camera_tricks.rs"] +#[path = "camera_output_tests.rs"] mod tests; diff --git a/crates/skate-game/src/physics/camera_output_tests.rs b/crates/skate-game/src/physics/camera_output_tests.rs new file mode 100644 index 00000000..17d71b21 --- /dev/null +++ b/crates/skate-game/src/physics/camera_output_tests.rs @@ -0,0 +1,45 @@ +use super::offboard_output; +use skate_core::player::input_phase::OffBoardOutputFields; + +// Host wiring regression, not an independently executed Skate 3 parity test. +#[test] +fn offboard_camera_consumes_the_distinct_completed_output_fields() { + let raw = |v: [f32; 4]| v.map(f32::to_bits); + let physical = OffBoardOutputFields { + scalar_92: 2.5, + scalar_152: 0.75, + scalar_156: 1.25, + vector_160: raw([0., 1., 0., 0.]), + vector_176: raw([1., 2., 3., 1.]), + vector_192: raw([0., 0., 1., 0.]), + vector_208: raw([4., 5., 6., 1.]), + vector_224: raw([1., 0., 0., 0.]), + vector_240: raw([7., 8., 9., 1.]), + trajectory_valid_331: 1, + flag_334: 1, + flag_308: 1, + flag_304: 0, + ..Default::default() + }; + let camera = offboard_output(&physical); + assert_eq!(camera.duration_92, 2.5); + assert_eq!(camera.time_152, 0.75); + assert_eq!(camera.apex_time_156, 1.25); + assert_eq!(camera.launch_normal_160, [0., 1., 0., 0.]); + assert_eq!(camera.launch_position_176, [1., 2., 3., 1.]); + assert_eq!(camera.landing_normal_192, [0., 0., 1., 0.]); + assert_eq!(camera.landing_position_208, [4., 5., 6., 1.]); + assert_eq!(camera.heading_224, [1., 0., 0., 0.]); + assert_eq!(camera.apex_240, [7., 8., 9., 1.]); + assert_eq!(camera.use_trajectory_331, 1); + assert_eq!(camera.dropping_in_334, 1); + assert_eq!(camera.object_held_304, 1); +} + +#[test] +fn reset_offboard_output_does_not_invent_a_valid_trajectory() { + let camera = offboard_output(&OffBoardOutputFields::default()); + assert_eq!(camera.use_trajectory_331, 0); + assert_eq!(camera.dropping_in_334, 0); + assert_eq!(camera.object_held_304, 0); +} diff --git a/crates/skate-game/src/physics/climbing/approach.rs b/crates/skate-game/src/physics/climbing/approach.rs index 30229269..dbc8bd11 100644 --- a/crates/skate-game/src/physics/climbing/approach.rs +++ b/crates/skate-game/src/physics/climbing/approach.rs @@ -183,8 +183,8 @@ fn update( carry_board: skater.skateboard_controller.fields.state_448 == 1, }); r.approach = None; - skater.offboard.air_prediction = None; - skater.offboard.feet.reset(); + skater.offboard_air_selector.reset(); + skater.offboard_feet.reset(); skater.foot_ik.state.enable_feet(false); bevy::log::info!("Climbing: caught ledge from stock jump"); } diff --git a/crates/skate-game/src/physics/climbing/tests.rs b/crates/skate-game/src/physics/climbing/tests.rs index d9193e4e..8aa63199 100644 --- a/crates/skate-game/src/physics/climbing/tests.rs +++ b/crates/skate-game/src/physics/climbing/tests.rs @@ -65,6 +65,7 @@ fn world(height: f32, width: f32, depth: f32, ceiling: bool, top: bool) -> Board static_edges: vec![], island_flags: 0, meshes: vec![QueryMesh { + geometry: 1, rejection_flags: 0, triangle_range: 0..count, pool: QueryPool::Ground, matching_group: 0, diff --git a/crates/skate-game/src/physics/controls.rs b/crates/skate-game/src/physics/controls.rs index 4a1e908a..f104b87a 100644 --- a/crates/skate-game/src/physics/controls.rs +++ b/crates/skate-game/src/physics/controls.rs @@ -20,6 +20,9 @@ pub(crate) struct PlayerControls { pub bumper_state_502: bool, pub bumper_state_104: bool, pub preferences: PushPreferences, + //One tick's native PlayerUI82898D20 result, shared by animation and PhysIn. + //None means the native offboard remap gate did not run, not missing camera. + offboard_axes: Option<[f32; 2]>, gestures: Option, } impl Default for PlayerControls { @@ -36,6 +39,7 @@ impl Default for PlayerControls { bumper_state_502: false, bumper_state_104: false, preferences: PushPreferences::default(), + offboard_axes: None, gestures: None, } } @@ -49,42 +53,95 @@ pub(super) fn sample( mut player: ResMut, ) { let mut map = input.0.actions(); - player.update( - &mut map, - physics.settings.step.simulation.time_step, - physics.settings.input_magnitude_threshold, - skater.player_input.physical.scoring.capabilities_204, + player + .update_for_physics(&mut map, &physics, &skater, &camera) + .unwrap_or_else(|error| panic!("Offboard controller publication: {error}")); + player.publish_gestures( + physics.animation_profile.physics_mode, + skater.player_input.physical.state.state_16, ); - if let Some(frame) = camera.frame { - let at = Vec3::from_array(frame.basis.columns[2]); - let forward = Vec3::new(at.x, 0., at.z).normalize_or_zero(); - if forward != Vec3::ZERO { - // Match camera::present: native camera Right becomes Bevy -Right. - let right = forward.cross(Vec3::Y); - let words = player.controller.words(); - let direction = right * f32::from_bits(words[7]) - + forward * f32::from_bits(words[8]); - player.offboard_direction = Some([direction.x, 0., direction.z, 0.]); - } - } - player.publish_gestures(physics.animation_profile.physics_mode, skater.player_input.physical.state.state_16); } impl PlayerControls { + /// PlayerUI82898920 transforms the gameplay packet before Raw/Derived input. + /// Only its offboard branch is enabled here; onboard behavior is unchanged. + pub fn update_for_physics( + &mut self, + map: &mut impl ActionMap, + physics: &GamePhysics, + skater: &SkaterRuntime, + camera: &crate::camera::CameraRuntime, + ) -> Result<(), String> { + let physical = &skater.player_input.physical; + //82DB7678/7694 writes category and State75 from the same category==500. + //82898B80..BC4 excludes grabbing an object (304) and state503. + let remap = physical.state.category_12 == 500 + && physical.off_board.flag_304 == 0 + && physical.state.state_16 != 503; + let axes = if remap { + let frame = camera + .frame + .as_ref() + .ok_or("Native offboard input requires a completed presentation camera frame")?; + Some(camera_relative_axes( + [map.value(64), map.value(65)], + frame.basis.columns, + )) + } else { + None + }; + self.update( + &mut SimulationActions { + source: map, + offboard_axes: axes, + }, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + physical.scoring.capabilities_204, + ); + self.offboard_axes = axes; + self.offboard_direction = axes.map(|v| [v[0], 0., v[1], 0.]); + Ok(()) + } + + /// Parent must pass this SAME sampled mapping into input_phase::advance. + /// Do not rotate again: the camera/state may have advanced since sampling. + pub fn simulation_actions<'a>(&self, source: &'a mut dyn ActionMap) -> SimulationActions<'a> { + SimulationActions { + source, + offboard_axes: self.offboard_axes, + } + } + pub fn load(root: &std::path::Path) -> Result { - Ok(Self { gestures: Some(crate::input::gesture_input::GestureInput::load(root)?), ..Self::default() }) + Ok(Self { + gestures: Some(crate::input::gesture_input::GestureInput::load(root)?), + ..Self::default() + }) } pub fn publish_gestures(&mut self, difficulty: u32, physical_state: u32) { if let Some(gestures) = &mut self.gestures { let words = self.controller.words(); - let axes = [[words[7],words[8]], [words[9],words[10]]].map(|p|p.map(f32::from_bits)); - gestures.publish(axes,difficulty,self.actor_flags,physical_state,&mut self.action_intents); + let axes = [[words[7], words[8]], [words[9], words[10]]].map(|p| p.map(f32::from_bits)); + gestures.publish( + axes, + difficulty, + self.actor_flags, + physical_state, + &mut self.action_intents, + ); } } - pub fn update(&mut self, map: &mut impl ActionMap, dt: f32, magnitude_threshold: f32, physical_capabilities: u32) { - self.offboard_direction = None; + pub fn update( + &mut self, + map: &mut impl ActionMap, + dt: f32, + magnitude_threshold: f32, + physical_capabilities: u32, + ) { + self.offboard_axes = None; self.controller.update( map, dt, @@ -98,16 +155,27 @@ impl PlayerControls { ); self.intents = riding_intentions::produce(&self.controller, self.actor_flags, self.preferences); - self.intents.extend(skate_core::input::manual_intentions::produce(&self.controller, self.actor_flags)); + self.intents + .extend(skate_core::input::manual_intentions::produce( + &self.controller, + self.actor_flags, + )); self.intents.extend(wipeout_intentions::produce( - &self.controller, self.actor_flags, physical_capabilities, + &self.controller, + self.actor_flags, + physical_capabilities, )); - self.intents.extend(skate_core::input::anticipation_intentions::produce(&self.controller)); - self.intents.extend(skate_core::input::grind_intentions::produce(&self.controller)); + self.intents + .extend(skate_core::input::anticipation_intentions::produce( + &self.controller, + )); self.intents.extend( skate_core::input::trick_intentions::produce(&self.controller) .into_iter() - .map(|intent| RidingIntent { name: intent.name, value: intent.value }), + .map(|intent| RidingIntent { + name: intent.name, + value: intent.value, + }), ); //GenerateActionGraphIntents82594310 clears the AG map through82BC1B68 //before Listener::Fill. MG lifecycle intents use a different persistent map. @@ -116,10 +184,81 @@ impl PlayerControls { self.action_intents.insert(intent.name, intent.value); } self.ticks += 1; - if self.ticks % 6 == 0 { - bevy::log::debug!(target: "skate_game::riding_trace", - tick = self.ticks, words = ?self.controller.words(), - intents = ?self.intents, "RIDING_INPUT"); - } } } + +/// Borrow the existing action packet; override only the native left-stick pair. +pub(crate) struct SimulationActions<'a> { + source: &'a mut dyn ActionMap, + offboard_axes: Option<[f32; 2]>, +} + +impl ActionMap for SimulationActions<'_> { + fn value(&mut self, action: u32) -> f32 { + if let Some(axes) = self.offboard_axes { + match action { + 64 => return axes[0], + 65 => return axes[1], + _ => {} + } + } + self.source.value(action) + } + + fn state(&mut self, action: u32) -> u8 { + if self.offboard_axes.is_some() && matches!(action, 64 | 65) { + return u8::from(self.value(action) != 0.0); + } + self.source.state(action) + } +} + +/// Complete numerical remap82898D20; camera is the native XYZ presentation +/// basis, before Bevy's presentation-only two-axis sign conversion. +fn camera_relative_axes(stick: [f32; 2], camera: [[f32; 3]; 3]) -> [f32; 2] { + let [mut right, mut up, mut forward] = camera; + //82898D98..DB4: ABS(dot(world_up, camera_Z)) < literal820ED5E8. + //Near either vertical pole, native retains ALL original camera axes. + if forward[1].abs() < f32::from_bits(0x3f7d_70a4) { + right = normalize_camera_axis(cross_camera_axis([0.0, 1.0, 0.0], forward)); + forward = normalize_camera_axis(cross_camera_axis(right, [0.0, 1.0, 0.0])); + up = normalize_camera_axis(cross_camera_axis(forward, right)); + } + //CacheLine includes its count at0.82898EE0..F0C reads Buttons17-16 + //and Buttons18-19: [-LeftAnalogStickLR, 0, LeftAnalogStickUD]. + let local = [-stick[0], 0.0, stick[1]]; + let world: [f32; 3] = std::array::from_fn(|i| { + forward[i].mul_add(local[2], up[i].mul_add(local[1], right[i] * local[0])) + }); + //82898F44..FAC splits world X/Z into positive/negative pad channels; + //stock actions64/65 subtract those channels again. Keep that ordering. + let negative_x = if world[0] >= 0.0 { 0.0 } else { -world[0] }; + let positive_x = if world[0] > 0.0 { world[0] } else { 0.0 }; + let negative_z = if world[2] >= 0.0 { 0.0 } else { -world[2] }; + let positive_z = if world[2] <= 0.0 { 0.0 } else { world[2] }; + [positive_x - negative_x, positive_z - negative_z] +} + +fn cross_camera_axis(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + ] +} + +fn normalize_camera_axis(v: [f32; 3]) -> [f32; 3] { + let squared = v[0] * v[0] + v[1] * v[1] + v[2] * v[2]; + //Independent PC rsqrt seed, retaining both native refinement iterations. + //No fitted epsilon/fallback and no claim of bit-exact Xenon emulation. + let mut inverse = squared.sqrt().recip(); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + v.map(|component| component * inverse) +} + +#[cfg(test)] +#[path = "controls/offboard_tests.rs"] +mod offboard_tests; diff --git a/crates/skate-game/src/physics/controls/offboard_tests.rs b/crates/skate-game/src/physics/controls/offboard_tests.rs new file mode 100644 index 00000000..7df0cca5 --- /dev/null +++ b/crates/skate-game/src/physics/controls/offboard_tests.rs @@ -0,0 +1,137 @@ +//! Numerical and adapter regressions, not original-executable parity tests. +use super::{ActionMap, PlayerControls, SimulationActions, camera_relative_axes}; + +fn close(actual: [f32; 2], expected: [f32; 2]) { + for (a, e) in actual.into_iter().zip(expected) { + assert!( + (a - e).abs() < 2.0e-6, + "actual={actual:?}, expected={expected:?}" + ); + } +} + +#[test] +fn offboard_remap_preserves_native_left_axis_sign_and_yaw() { + let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + close(camera_relative_axes([0.25, 0.75], identity), [-0.25, 0.75]); + let quarter_turn = [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]; + close( + camera_relative_axes([0.25, 0.75], quarter_turn), + [0.75, 0.25], + ); + close( + camera_relative_axes([-0.25, -0.75], quarter_turn), + [-0.75, -0.25], + ); + close(camera_relative_axes([0.0, 0.0], quarter_turn), [0.0, 0.0]); +} + +#[test] +fn offboard_remap_flattens_pitch_below_native_threshold() { + let pitched = [[1.0, 0.0, 0.0], [0.0, 0.8, -0.6], [0.0, 0.6, 0.8]]; + close(camera_relative_axes([0.25, 0.75], pitched), [-0.25, 0.75]); +} + +#[test] +fn offboard_remap_retains_camera_basis_at_both_threshold_poles() { + let threshold = f32::from_bits(0x3f7d_70a4); + let horizontal = (1.0 - threshold * threshold).sqrt(); + for vertical in [threshold, -threshold] { + let pitched = [ + [1.0, 0.0, 0.0], + [0.0, horizontal, -vertical], + [0.0, vertical, horizontal], + ]; + close( + camera_relative_axes([0.25, 0.75], pitched), + [-0.25, 0.75 * horizontal], + ); + } + //An exactly vertical camera does not invent a horizontal forward direction. + let vertical = [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]]; + close(camera_relative_axes([0.0, 1.0], vertical), [0.0, 0.0]); +} + +struct Packet { + values: [f32; 82], + states: [u8; 82], +} + +impl Packet { + fn distinct() -> Self { + Self { + values: std::array::from_fn(|i| i as f32 / 100.0), + states: std::array::from_fn(|i| (i % 2) as u8), + } + } +} + +impl ActionMap for Packet { + fn value(&mut self, action: u32) -> f32 { + self.values[action as usize] + } + fn state(&mut self, action: u32) -> u8 { + self.states[action as usize] + } +} + +#[test] +fn offboard_simulation_adapter_overrides_only_the_sampled_pair() { + let mut packet = Packet::distinct(); + let values = packet.values; + let states = packet.states; + let mut controls = PlayerControls::default(); + controls.offboard_axes = Some([-0.25, 0.0]); + let mut mapped = controls.simulation_actions(&mut packet); + //The adapter owns the sampled pair, not a borrow of mutable controller state. + controls.offboard_axes = Some([0.9, 0.8]); + for action in 0..82 { + let (value, state) = match action { + 64 => (-0.25, 1), + 65 => (0.0, 0), + _ => (values[action as usize], states[action as usize]), + }; + assert_eq!(mapped.value(action).to_bits(), value.to_bits()); + assert_eq!(mapped.state(action), state); + } +} + +#[test] +fn offboard_inactive_adapter_preserves_every_onboard_action() { + let mut packet = Packet::distinct(); + let values = packet.values; + let states = packet.states; + let controls = PlayerControls::default(); + let mut mapped = controls.simulation_actions(&mut packet); + for action in 0..82 { + assert_eq!( + mapped.value(action).to_bits(), + values[action as usize].to_bits() + ); + assert_eq!(mapped.state(action), states[action as usize]); + } +} + +#[test] +fn offboard_sample_reaches_derived_history_and_raw_tick_clears_override() { + let mut packet = Packet::distinct(); + let mut controls = PlayerControls::default(); + controls.update( + &mut SimulationActions { + source: &mut packet, + offboard_axes: Some([-0.25, 0.75]), + }, + 1.0 / 60.0, + 0.1, + 0, + ); + assert_eq!(controls.controller.words()[7], (-0.25f32).to_bits()); + assert_eq!(controls.controller.words()[8], 0.75f32.to_bits()); + controls.offboard_axes = Some([-0.25, 0.75]); + controls.update(&mut packet, 1.0 / 60.0, 0.1, 0); + assert_eq!(controls.offboard_axes, None); + assert_eq!(controls.controller.words()[0], (-0.25f32).to_bits()); + assert_eq!(controls.controller.words()[1], 0.75f32.to_bits()); + assert_eq!(controls.controller.words()[7], packet.values[64].to_bits()); + assert_eq!(controls.controller.words()[8], packet.values[65].to_bits()); +} diff --git a/crates/skate-game/src/physics/foot_ik.rs b/crates/skate-game/src/physics/foot_ik.rs index c8da136b..44a6a0c5 100644 --- a/crates/skate-game/src/physics/foot_ik.rs +++ b/crates/skate-game/src/physics/foot_ik.rs @@ -42,9 +42,6 @@ pub(crate) struct PhysicalInput<'a> { pub(crate) struct DriveOutput { pub frames: [Transform; 24], - pub updated_limbs: [bool; 4], - pub support_failed: bool, - pub support_failed_this_update: bool, } impl FootIk { @@ -139,7 +136,6 @@ impl FootIk { let mut drives = skeleton.record.pose; //GeneralUpdate skips all IK stages for702 but still submits mapped //animation drives. Preserve histories across that skipped update. - let mut updated_limbs = [false; 4]; if input.state_id != 702 { let mut originals = skeleton.record.pose; for (part, &bone) in self.bone_indices.iter().enumerate() { @@ -161,7 +157,7 @@ impl FootIk { &skeleton.roots.inverse_board, ) }; - updated_limbs = self.state.update( + let _ = self.state.update( UpdateInput { flags_2468: input.flags_2468, flags_2472: input.flags_2472, @@ -184,12 +180,7 @@ impl FootIk { &mut drives, ); } - Ok(DriveOutput { - frames: drives, - updated_limbs, - support_failed: self.state.contacts.support_failed, - support_failed_this_update: self.state.contacts.support_failed_this_update, - }) + Ok(DriveOutput { frames: drives }) } } diff --git a/crates/skate-game/src/physics/foot_ik_queries.rs b/crates/skate-game/src/physics/foot_ik_queries.rs index 487571c8..7723fdba 100644 --- a/crates/skate-game/src/physics/foot_ik_queries.rs +++ b/crates/skate-game/src/physics/foot_ik_queries.rs @@ -54,12 +54,6 @@ impl SkeletonLineTests { player.left_line_test_1536 = fields(self.feet[0]); player.right_line_test_1584 = fields(self.feet[1]); } - - /// These observations become Processed960/1008. SkeletonIK retains its - ///own previous query and consumes it before importing this pair. - pub fn foot_contacts(&self) -> [Option<[f32; 4]>; 2] { - self.feet.map(|foot| foot.hit.then_some(foot.position)) - } } pub(crate) fn query(world: &BoardWorld, body: &SkeletonBody) -> Result { diff --git a/crates/skate-game/src/physics/foot_physical_output.rs b/crates/skate-game/src/physics/foot_physical_output.rs index 9b07c843..4f90c06a 100644 --- a/crates/skate-game/src/physics/foot_physical_output.rs +++ b/crates/skate-game/src/physics/foot_physical_output.rs @@ -25,10 +25,6 @@ impl FootPhysicalOutputs { }, }) } - pub fn reset(&mut self) { - self.state.reset(); - self.output = FootPhysicalOutput::default(); - } pub fn publish(&mut self, record: &SkeletonPhysicalRecord, dt: f32) -> FootPhysicalOutput { self.output = self.state.update(record, dt, self.settings); self.output diff --git a/crates/skate-game/src/physics/footplant/ground.rs b/crates/skate-game/src/physics/footplant/ground.rs index 73dcedb5..41027938 100644 --- a/crates/skate-game/src/physics/footplant/ground.rs +++ b/crates/skate-game/src/physics/footplant/ground.rs @@ -159,7 +159,7 @@ pub(in crate::physics) fn update( info.player_jumped = true; let input = air_phase::selector_input(physics, skater)?; skater.trajectory.launch(info, input, &physics.world)?; - skater.trajectory.update(input, &physics.world)?; + skater.trajectory.update(input, &physics.world, crate::physics::air_trajectory::GrindContext::from_processed(&skater.player_input.processed, crate::physics::solve::deck_frame(&physics.board)[3]))?; } Ok(()) } diff --git a/crates/skate-game/src/physics/footplant/update.rs b/crates/skate-game/src/physics/footplant/update.rs index b41fa13d..772f1c52 100644 --- a/crates/skate-game/src/physics/footplant/update.rs +++ b/crates/skate-game/src/physics/footplant/update.rs @@ -162,7 +162,7 @@ impl Footplant { ///82D6FBB0: query the real edge geometry within the native two-metre box. fn nearby_edge(&mut self, frame: &FootplantFrame<'_>) { - use skate_core::{math::Vector3, player::offboard::{air_ledge::visible_edges, ground_query::Edge}}; + use skate_core::{math::Vector3, player::offboard::{air_selector::ledge::filter_edges as visible_edges, ground_query::Edge}}; let xyz = |v: V| Vector3::new(v[0], v[1], v[2]); let lanes = |v: Vector3| [v.x, v.y, v.z, 0.0]; let edges: Vec<_> = frame.edges.iter().filter(|e| (0..3).all(|i| diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 87c0cb32..47ec3652 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -5,7 +5,14 @@ use super::{ input_phase, player_state, solve, }; use crate::{camera::CameraRuntime, graph_runtime::StockGraphs}; -use skate_core::{input::controller::ActionMap, math::Vector3, physics::{board::BodyId, phase::{PhysicsEvent, PhysicalOutputSnapshot}}}; +use skate_core::{ + input::controller::ActionMap, + math::Vector3, + physics::{ + board::BodyId, + phase::{PhysicalOutputSnapshot, PhysicsEvent}, + }, +}; pub(super) fn advance( physics: &mut GamePhysics, @@ -17,6 +24,9 @@ pub(super) fn advance( camera: &mut CameraRuntime, ) -> Result<(), String> { let tick = physics.ticks; + super::offboard_audit_trace::stage(tick, "begin", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "begin", skater); physics.exchange = super::SimulationExchange::new(tick); //SimController8285C968 dispatches the preceding tick's camera messages //before simulation. Keep End/Begin ordering when both occur in one update. @@ -31,20 +41,18 @@ pub(super) fn advance( //Publishing Ground here runs its ForcePhysics Begin before the initial //input reset82DB8998, which would immediately clear that mode again. } - if super::climbing::advance(physics, skater, controls, camera)? { - return Ok(()); - } - if skater.trajectory.edges.is_empty() { skater.trajectory.edges.clone_from(&physics.grind.primitives); } + if super::climbing::advance(physics, skater, controls, camera)? { return Ok(()); } + //8285A06C/A080 completes prior trajectory batches, then A0D0 calls + //Player slot16=82DB3C78 (vtable82328688). Consume with retained input + //BEFORE this frame's queries, ProcessInput, and state selection. + super::biped_air::consume_selector(physics, skater)?; physics.board.clear_forces(); - let query_timer = crate::performance::Scope::new("wheel_and_foot_queries"); //World8275EA20 starts both batches before actor SetUpPhysics. Foot //results stay pending while PlayerInput consumes the preceding records. physics .riding .start_wheel_queries(&physics.board, &physics.world)?; let skeleton_queries = super::foot_ik_queries::query(&physics.world, &skater.skeleton)?; - drop(query_timer); - let graph_timer = crate::performance::Scope::new("animation_graph"); let animation = animation_phase::advance( physics, skater, @@ -53,35 +61,36 @@ pub(super) fn advance( &physics.animation_profile, ) .map_err(|e| format!("Animation tick{}: {e}", physics.ticks))?; - drop(graph_timer); - let input_timer = crate::performance::Scope::new("input_and_state"); - //82DB4048 completes the prior Sync's Biped contact queries before input - //reset and state PreUpdate, including updates while another state is active. - skater.offboard.begin_player_update(); + super::offboard_audit_trace::stage(tick, "animation", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "animation", skater); //ForcePhysics Begin82BB2868 writes the live Skeleton16420 mode once. //Consume the graph request so a later physical reset can retain its own0. if let Some(mode) = skater.animation.motion.riding.force_mode.take() { skater.skeleton_input.force_mode = mode.native_value(); } + //PlayerUI82898920 remaps the gameplay packet before both controller + //and physical input consume it. Reuse this tick's sampled mapping. + let mut simulation_actions = controls.simulation_actions(actions); let teleported = input_phase::advance( physics, skater, &animation.packet(), - actions, + &mut simulation_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, - )?; + skater.ground_settings = skater.ground_profiles.select(skater.player_input.processed.state_variant_index_2528, skater.player_input.processed.surface_mode_2540)?; if teleported { skater.respawn.reset_measurements(); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "teleport", skater); + //82DB93B0..CC: complete pending queries and clear contact history. + skater.offboard_contact.reset_history(); skater.player_state.reset_for_teleport(); skater.centre_of_mass_filter.reset(); skater.animation_feedback.reset(); player_state::enter_after_teleport(physics, skater)?; + super::offboard::board_manager::runtime::finish_teleport(physics, skater); //82DB8E40 calls SetUpNormal after pose reset, state100 and board reset. skater.wipeout_state.ragdoll.restore_normal( &mut skater.skeleton, @@ -92,9 +101,20 @@ pub(super) fn advance( } //World8275EC0C ends board queries before PostInput/state selection. physics.riding.finish_wheel_queries()?; + //Complete preceding-state submissions before PostInput82DB573C publishes + //them. Host execution is synchronous; no current-state submission exists + //yet, preserving next-tick visibility and later PreState82DB60EC consumption. + { + let scene = + super::offboard::grab_scene::Scene::new(&physics.world, &physics.offboard_grab_scene); + skater.offboard_grab.execute_queries(&scene)?; + } let state_before_selection = skater.player_state.current(); player_state::post_input_and_select(physics, skater)?; let state_after_selection = skater.player_state.current(); + super::offboard_audit_trace::stage(tick, "selected", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "selected", skater); if state_before_selection != state_after_selection { if skater.player_input.processed.flags_2476 & (1 << 22) != 0 || state_before_selection == skate_core::player::state::PhysicalStateId::HandPlant @@ -118,19 +138,6 @@ pub(super) fn advance( skate_core::player::state::PhysicalStateId::HandPlant => super::handplant::update(physics,skater)?, skate_core::player::state::PhysicalStateId::FootPlant => super::footplant::ground::update(physics, skater)?, skate_core::player::state::PhysicalStateId::Boneless => super::boneless::update(physics, skater)?, - skate_core::player::state::PhysicalStateId::GrindBoardslide - | skate_core::player::state::PhysicalStateId::GrindFiftyFifty - | skate_core::player::state::PhysicalStateId::GrindTipslide - | skate_core::player::state::PhysicalStateId::GrindFiveO - | skate_core::player::state::PhysicalStateId::GrindBackslash - | skate_core::player::state::PhysicalStateId::GrindDarkslide - | skate_core::player::state::PhysicalStateId::Nonspecific => super::grind::update(physics, skater)?, - skate_core::player::state::PhysicalStateId::BipedAir => { - super::offboard::air_state::update(physics, skater)?; - } - skate_core::player::state::PhysicalStateId::BipedGround => { - super::offboard::ground_state::update(physics, skater)?; - } skate_core::player::state::PhysicalStateId::PhysicsGround => { let p = &skater.player_input.processed; let com = p.animation_com_to_deck_752.map(f32::from_bits); @@ -143,6 +150,7 @@ pub(super) fn advance( p.flags_2468, skater.animation_input.fields.balance, p.flags_2476 & 0x4000_0000 != 0, + p, ); //PhysicsGround::PreUpdate82D37C50 clears the previous push suppression //after Reckoning; this frame's propulsion may then set it again. @@ -157,9 +165,31 @@ pub(super) fn advance( skate_core::player::state::PhysicalStateId::KnownAir => { super::known_air::update(physics, skater)? } + skate_core::player::state::PhysicalStateId::BipedAir => { + super::biped_air::update(physics, skater, controls)? + } + skate_core::player::state::PhysicalStateId::BipedGround + | skate_core::player::state::PhysicalStateId::OffBoardPushing => { + let contact = skate_core::player::offboard::ground_job::ContactSnapshot { + readiness: skater.offboard_contact.readiness() as i32, + prefix: skater.offboard_contact.prefix(), + }; + let input = super::biped_ground::update(physics, skater, controls, contact)?; + //Sync82D32164 submits AFTER Skeleton; both query batches become + //visible only to their next input/PreUpdate consumer. + skater.offboard_contact.submit( + input, + skater.player_input.processed.actor_query_2952 as i32, + &super::offboard::contact_toolkit::StaticScene::new(&physics.world)?, + )?; + super::biped_ground::submit_geometry(physics, skater)?; + } skate_core::player::state::PhysicalStateId::GroundAnimation => { super::ground_animation::advance(physics, skater)? } + skate_core::player::state::PhysicalStateId::LandingOnDeck => { + super::landing_on_deck::advance(physics, skater)? + } skate_core::player::state::PhysicalStateId::SlideGround => { super::slide_state::update(physics, skater)? } @@ -172,34 +202,53 @@ pub(super) fn advance( super::respawn::request(physics, skater)?; } } + state + if state.is_grind() + || state == skate_core::player::state::PhysicalStateId::Nonspecific => + { + super::grind::advance(physics, skater)? + } state => return Err(format!("Selected {state:?} requires its physical update")), } - super::offboard::possession::update(physics, skater)?; // Source State82DB6120 submits the queue once, then advances state time. // BoardRuntime applies that same queue during its shared solve. skater.player_input.player.state_timer_1344 += skater.player_input.processed.timestep_2604; + super::offboard_audit_trace::stage(tick, "state", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "state", skater); + //82DB6150: update the real possession owner after state movement, before solve. + super::offboard::board_manager::runtime::update(physics, skater); + super::offboard_audit_trace::stage(tick, "possession", physics, skater, controls); physics.processed_flags_2468 = skater.player_input.processed.flags_2468; //World8275ECA4 ends skeleton tests after state/forces and before solving. //Teleport resets previous observations, but preserves this pending batch. skeleton_queries.publish(&mut skater.player_input.player); - drop(input_timer); - let solve_timer = crate::performance::Scope::new("solve_total"); solve::advance(physics, skater, skater.ground.steering.targets)?; - drop(solve_timer); - let _output_timer = crate::performance::Scope::new("outputs_and_camera"); + super::offboard_audit_trace::stage(tick, "solve", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "solve", skater); //ProcessOutput82DB6EE8 resets the packet before its component publishers. //All consumers of the preceding output have completed this frame's input. super::player_input::reset_outputs(&mut skater.player_input.physical); physics.finish_skater(skater)?; + super::offboard_audit_trace::stage(tick, "finish", physics, skater, controls); + #[cfg(test)] + super::offboard_root_trace::trace(tick, "finish", skater); let simulation = physics.settings.step.simulation; - skater.player_input.update_dynamic_normal( - &physics.board, - &physics.riding, - simulation.gravity_acceleration, - simulation.time_step, - ); + skater + .player_input + .update_dynamic_normal(&physics.riding, simulation.gravity_acceleration); skater.player_input.publish_board(&physics.riding)?; + let up = physics.riding.reckoning.up; + skater.player_input.publish_grind_graph_outputs( + &skater.skeleton.record, + [up.x, up.y, up.z, 0.0], + &skater.trajectory.selector, + )?; player_state::publish(physics, skater)?; + skater + .grind_camera + .condition_fields(&mut skater.player_input.physical.grinds); let physical = &skater.player_input.physical; skater.centre_of_mass_output = skater.centre_of_mass_filter.update( physical.reckoning.vector_64.map(f32::from_bits), @@ -236,7 +285,9 @@ pub(super) fn advance( skate_core::player::state::PhysicalStateId::PhysicsGround ), wiping_out: skater.skeleton_collision.is_ragdoll, - landed: events.iter().any(|event| matches!(event, PhysicsEvent::Landing)), + landed: events + .iter() + .any(|event| matches!(event, PhysicsEvent::Landing)), events, }); camera_output::advance(physics, skater, &feedback, camera)?; @@ -249,7 +300,7 @@ pub(super) fn advance( tick: tick as u32, dt: simulation.time_step, category: filtered.map_or(Default::default(), |f|f.category), state: skater.player_state.current() as u32, - descriptor: score.trick.or_else(||score.grab.map(|g|g.0)), + descriptor: score.trick_names.first.or_else(||score.grab.map(|g|g.0)), grind_id: filtered.map_or(-1, |f|f.grind.scorable_id), flags:score.flags, position:[position.x,position.y,position.z], velocity:[velocity.x,velocity.y,velocity.z], forward:deck_frame.basis.columns[2], diff --git a/crates/skate-game/src/physics/grind.rs b/crates/skate-game/src/physics/grind.rs index 71cca7ef..15a305f0 100644 --- a/crates/skate-game/src/physics/grind.rs +++ b/crates/skate-game/src/physics/grind.rs @@ -1,862 +1,66 @@ -//! Live straight-spline grind owner. TU3 truck query82C1FDC0, paired scorer -//!82D89150, 50-50 update82D41D70 and exit82D3F430. -use super::{GamePhysics, SkaterRuntime}; -use skate_core::riding::ground_correction_math::dot_product as dot3; -use skate_core::{ - animation::skeleton_input::name::encode, - math::{Basis3, Vector3}, - physics::{ - drive_frames::RetailAffineTransform, - filtered_state::GrindState, - force_queue::QueuedPointForce, - grind_contact::{self, FiftyFiftyCandidate, Primitive}, - grind_forces, - }, - player::state::PhysicalStateId, - point_graph::PointGraph, -}; -use skate_data::collections::Collections; -type V = [f32; 4]; -#[path = "grind_camera.rs"] -mod camera; -#[path = "grind_chromosome.rs"] -mod chromosome; -pub(crate) struct Runtime { - camera: camera::GrindCamera, - pub(super) primitives: Vec, - pub candidate: Option, - pub name: String, - pub active: bool, - ///82D875A8 fallback result412 bit27: raw contact without accepted family. - pub checkpoint_suppressed: bool, - pub kind: u32, - pub front_contact: bool, - chromosome: chromosome::Chromosome, - scoring_name: String, - ground_frames: u32, - crouch: f32, - control: grind_contact::control::Control, - test_depth_epsilon: f32, - test_depth: f32, - exiting: bool, - release: grind_forces::Release, - surface_kind: u32, - surface_normal: V, - surface_side: V, - entry_velocity: Option, - maximum_entry_delta: f32, - truck_to_wheel: f32, - deck_to_truck: f32, - pin_vs_slope: PointGraph<4>, - pub distance: f32, - pop_heights: [[f32; 2]; 5], - boardslide_pop_heights: [[f32; 2]; 5], - tipslide_pop_heights: [[f32; 2]; 5], - manager_age: f32, - pub launched: bool, - pub launch_velocity: V, - cooldown: u32, - previous_position: V, - diagnostic_tick: u32, -} -impl Runtime { - pub fn load( - data: &Collections, - map: Option<&skate_data::skate_map::SkateMap>, - ) -> Result { - let graph = data - .words::<8>("physics_grinds", "default", "PinVsSlope")? - .map(f32::from_bits); - Ok(Self { - camera: camera::GrindCamera::default(), - primitives: crate::grind_world::primitives(map)?, - candidate: None, - name: String::new(), - active: false, - checkpoint_suppressed: false, - kind: 0, - front_contact: false, - chromosome: chromosome::Chromosome::default(), - scoring_name: String::new(), - ground_frames: 0, - crouch: 0.0, - control: grind_contact::control::Control::default(), - test_depth_epsilon: data.float("physics_grinds", "default", "TestDepthEpsilon")?, - test_depth: data.float("physics_grinds", "default", "TestDepth")?, - exiting: false, - release: grind_forces::Release::default(), - surface_kind: 0, - surface_normal: [0.0, 1.0, 0.0, 0.0], - surface_side: [0.0; 4], - entry_velocity: None, - maximum_entry_delta: data.float("physics_wipeout", "default", "Wipeout_AirMaxSpeedIntoCollisionNearGrind")?, - truck_to_wheel: data.float("physics_grinds", "default", "TruckToWheel")?, - deck_to_truck: data.float("physics_grinds", "default", "DeckCenterToTruck")?, - pin_vs_slope: PointGraph { - x: graph[..4].try_into().unwrap(), - y: graph[4..8].try_into().unwrap(), - }, - pop_heights: ["easy", "normal", "hardcore", "motorized", "test"] - .map(|key| { - Ok::<_, String>([ - data.float("physics_mode", key, "Hash_3097A69281990652")?, - data.float("physics_mode", key, "Hash_FA4CDBAE0DFD1FAD")?, - ]) - }) - .into_iter() - .collect::, _>>()? - .try_into() - .unwrap(), - //82D41CE8 consumes mode100/104;50-50's82D40D60 uses92/96. - boardslide_pop_heights: ["easy", "normal", "hardcore", "motorized", "test"] - .map(|key| { - Ok::<_, String>([ - data.float("physics_mode", key, "Hash_B2B1170AFFC8AC69")?, - data.float("physics_mode", key, "Hash_1B3E9F9C836D287D")?, - ]) - }) - .into_iter() - .collect::, _>>()? - .try_into() - .unwrap(), - tipslide_pop_heights: ["easy", "normal", "hardcore", "motorized", "test"] - .map(|key| { - Ok::<_, String>([ - data.float("physics_mode", key, "Hash_703829BD711E54DE")?, - data.float("physics_mode", key, "Hash_F2473E9125079F0")?, - ]) - }) - .into_iter() - .collect::, _>>()? - .try_into() - .unwrap(), - manager_age: 0.0, - launched: false, - launch_velocity: [0.; 4], - cooldown: 0, - distance: 0.0, - previous_position: [0.; 4], - diagnostic_tick: 0, - }) - } - pub fn advance_camera(&mut self, velocity: V) -> Result<(), String> { - if self.active { - let contact = self.candidate.ok_or("Grind camera needs active contact")?; - self.camera.update( - contact, - self.primitives[contact.primitive], - self.kind, - velocity, - ); - } else { - self.camera.exit(); - } - Ok(()) - } - pub fn camera_output(&self) -> crate::camera::CameraGrindOutput { - crate::camera::CameraGrindOutput { - //Reset82DE3518 defaults still apply outside the active state. - direction_0: if self.active { - self.camera.direction - } else { - [1.0, 0.0, 0.0, 0.0] - }, - camera_target_96: if self.active { - self.camera.target - } else { - [0.0; 4] - }, - grinding_316: u8::from(self.active), - } - } - pub fn output(&self) -> GrindState { - if !self.active { - return GrindState::default(); - } - let owner = self - .candidate - .map(|c| self.primitives[c.primitive].owner) - .unwrap_or(u64::MAX); - GrindState { - kind: self.kind as i32, - name: encode(self.name.as_bytes()), - scoring_name: encode(self.scoring_name.as_bytes()), - on_front: self.front_contact, - crouch: self.crouch, - pathed_guid: owner, - local_guid: owner, - ..GrindState::default() - } - } -} -///Run after ProcessInput, before the native state selector. Geometry is queried -///against both current trucks; merely being near a rail cannot acquire it. -pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { - let p = &mut skater.player_input.processed; - let runtime = &mut physics.grind; - runtime.entry_velocity = None; - runtime.checkpoint_suppressed = false; - let board = super::solve::deck_frame(&physics.board); - let mut effective_board = board; - if p.flags_2468 & 0x0010_0000 != 0 { - effective_board[0] = effective_board[0].map(|v| -v); - effective_board[2] = effective_board[2].map(|v| -v); - } - let pose = chromosome::Pose { - animated_board: skater.animated_skeleton.board_frames.animation_target, - board: effective_board, - foot_directions: [ - skater.skeleton.record.pose[19][0], - skater.skeleton.record.pose[15][0], - ], - fakie: skater.animation.packet.riding_fakie, - }; - runtime.chromosome.observe(pose, p.category_2512); - runtime.cooldown = runtime.cooldown.saturating_sub(1); - runtime.manager_age = (runtime.manager_age + 0.0035).min(1.0); - let old = runtime.candidate; +//! ZIP grind runtime split into source-closed components against CURRENT APIs. +//! Physical acquisition remains with the input manager. The retained runtime +//! owns family state, body operations and publication, never candidate selection. +pub(crate) mod acquisition; +mod advance; +mod arithmetic; +pub(crate) mod board; +mod condition; +mod contact; +pub(crate) mod forces; +mod involuntary; +pub(crate) mod launch; +pub(crate) mod lifecycle; +pub(crate) mod observation; +pub(crate) mod output; +mod phase; +pub(crate) mod post; +mod rng; +mod runtime; +mod settings; +mod state; +mod substate; +mod tip_nudge; +mod wipeout_output; +pub(crate) use advance::advance; +pub(crate) use condition::condition; +pub(crate) use phase::{enter, exit, fill, post}; +pub(crate) use runtime::Runtime; - runtime.candidate = None; - runtime.diagnostic_tick = runtime.diagnostic_tick.wrapping_add(1); - runtime.ground_frames = if p.category_2512 == 100 { - runtime.ground_frames.saturating_add(1) - } else { - 0 - }; - let gated = runtime.cooldown > 0 - || skater.player_input.grind.disabled - || p.flags_2476 & 0x01000000 != 0; - // Native GrindData query82D8A828: a 1.2m half-extent, at most40 chords. - let nearby: Vec = runtime - .primitives - .iter() - .enumerate() - .filter(|(_, edge)| { - (0..3).all(|i| { - edge.start[i].min(edge.end[i]) <= board[3][i] + 1.2 - && edge.start[i].max(edge.end[i]) >= board[3][i] - 1.2 - }) - }) - .map(|(i, _)| i) - .take(40) - .collect(); - if nearby.is_empty() { - return; - } - let edges: Vec = nearby.iter().map(|i| runtime.primitives[*i]).collect(); - let mut hits = grind_contact::truck_contacts( - board, - p.flags_2484, - runtime.truck_to_wheel, - runtime.deck_to_truck, - &edges, - ); - for hit in hits.iter_mut().flatten() { - hit.primitive = nearby[hit.primitive]; - } - let extra = &skater.animation_input.extra; - let fifty = grind_contact::fifty_fifty_candidate_on_splines( - board, - [extra.grind_translation, extra.grind_stability_nudge], - hits, - &runtime.primitives, - ); - let velocity = p.vectors_400_416[0].map(f32::from_bits); - let deck_hit = grind_contact::deck_contact( - board, - p.flags_2484, - runtime.test_depth_epsilon, - runtime.test_depth, - runtime.deck_to_truck, - &edges, - ); - let slide = deck_hit.and_then(|mut hit| { - hit.primitive = nearby[hit.primitive]; - grind_contact::boardslide_candidate( - board, - hit, - runtime.primitives[hit.primitive], - velocity, - p.category_2512, - skater.player_input.grind.low_wheel_frames as i32, - p.flags_2476, - runtime.deck_to_truck, - runtime.truck_to_wheel, - ) - }); - use grind_contact::families::{self, Contact}; - let five = families::five_o( - board, - hits, - &runtime.primitives, - velocity, - p.flags_2476, - p.flags_2472, - runtime.ground_frames, - extra.grind_translation, - ); - let mut tip_hits = families::tip_contacts( - board, - p.flags_2484, - p.state_2504, - p.flags_2468, - runtime.deck_to_truck, - &edges, - ); - for hit in tip_hits.iter_mut().flatten() { - hit.primitive = nearby[hit.primitive]; - } - let tip = families::tipslide( - board, - tip_hits, - &runtime.primitives, - velocity, - p.category_2512, - p.flags_2476, - p.flags_2472, - runtime.ground_frames, - skater.animation_input.fields.balance, - p.vectors_464_480_496_512_528[0].map(f32::from_bits), - ); - let inverted_hit = families::inverted_contact( - board, - p.flags_2484, - runtime.test_depth_epsilon, - runtime.test_depth, - &edges, - ); - let dark = inverted_hit.and_then(|mut hit| { - hit.primitive = nearby[hit.primitive]; - families::darkslide( - board, - hit, - runtime.primitives[hit.primitive], - velocity, - p.category_2512, - skater.player_input.grind.low_wheel_frames, - ) - }); - let fifty = fifty.map(|geometry| Contact { - geometry, - front: false, - kind: 0, - }); - let slide = slide.map(|geometry| Contact { - geometry, - front: false, - kind: 1, - }); - //82D875A8 retains a valid current family, then truck/tip/deck/inverted. - let current = if runtime.active { - match runtime.kind { - 0 | 3 => fifty.or(five), - 1 => slide, - 2 | 4 => tip, - 5 => dark, - _ => None, - } - } else { - None - }; - let selected = current.or(fifty).or(five).or(tip).or(slide).or(dark); - runtime.checkpoint_suppressed = (gated || selected.is_none()) - && (hits.iter().any(Option::is_some) || deck_hit.is_some() - || inverted_hit.is_some() || tip_hits.iter().any(Option::is_some)); - let candidate = selected.map(|c| c.geometry); - let mut kind = selected.map_or(0, |c| c.kind); - runtime.front_contact = selected.is_some_and(|c| c.front); - // Passive diagnostics for the user's visual run; no input simulation. - if runtime.diagnostic_tick % 30 == 0 || (!gated && candidate.is_some()) != old.is_some() { - bevy::log::info!( - "GRIND_QUERY state={} gated={} nearby={} hits={:?} depth={:?} candidate={} board={:?} up={:?} forward={:?} probe={:?} flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", - p.state_2508, - gated, - nearby.len(), - hits.map(|h| h.map(|h| h.primitive)), - hits.map(|h| h.map(|h| dot3(sub(board[3], h.position), board[1]))), - candidate.is_some(), - board[3], - board[1], - board[2], - [runtime.truck_to_wheel, runtime.deck_to_truck], - p.flags_2468, - p.flags_2472, - p.flags_2476, - p.flags_2480, - p.flags_2484 - ); - } - if gated { - return; - } - let Some(mut candidate) = candidate else { - return; - }; - let velocity = p.vectors_400_416[0].map(f32::from_bits); - //82D886B8: air accepts an actual intersection; same active grind uses90deg. - //The ordinary level-ground admission uses17deg. - let angle = if p.category_2512 == 400 || p.state_2508 == 701 { - 90.0 - } else { - 17.0 - }; - if p.category_2512 != 200 - && !grind_contact::within_approach_angle(candidate.direction, velocity, angle) - { - runtime.checkpoint_suppressed = true; - return; - } - runtime.candidate = Some(candidate); - runtime.kind = kind; - { - let edge = runtime.primitives[candidate.primitive]; - match skate_core::air::trajectory::grind_surface::investigate( - &physics.world, - edge.start, - edge.end, - candidate.centre, - runtime.deck_to_truck, - ) { - Ok(Some(surface)) => { - if surface.evidence.kind == 3 { - runtime.candidate = None; - return; - } - runtime.surface_kind = surface.evidence.kind; - runtime.surface_normal = surface.normal; - runtime.surface_side = surface.evidence.side; - if kind == 2 - && families::is_backslash( - board[3], - candidate.centre, - surface.far_points, - surface.normal, - runtime.deck_to_truck, - p.state_2508, - ) - { - kind = 4; - runtime.kind = kind; - } - } - Ok(None) | Err(_) => { - runtime.candidate = None; - return; - } - } - } - //82D87460: primitive tangent follows travel, retaining its previous sign at rest. - let edge = runtime.primitives[candidate.primitive]; - let delta = sub(edge.end, edge.start); - let length = dot3(delta, delta).sqrt(); - let mut direction = delta.map(|v| v / length); - let along = dot3(direction, velocity); - if along < 0.0 { - direction = direction.map(|v| -v); - } - if along.abs() < 0.1 && dot3(p.grind_direction_1136.map(f32::from_bits), direction) < -0.9 { - direction = direction.map(|v| -v); - } - candidate.direction = direction; - runtime.candidate = Some(candidate); - p.grind_position_1120 = candidate.centre.map(f32::to_bits); - p.grind_direction_1136 = direction.map(f32::to_bits); - let normal = if kind == 0 { - grind_contact::upright_normal(direction) - } else { - runtime.surface_normal - }; - //82D86F00 uses Processed608, the retained airborne trajectory velocity, - //not the board's post-contact400. Consume once after state selection. - if p.category_2512 == 200 { - let air_velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); - let corrected = grind_contact::entry::airborne_velocity(kind, direction, normal, air_velocity); - let rejected = grind_contact::entry::airborne_rejections( - direction, p.vectors_544_560_592_608[0].map(f32::from_bits), velocity, - air_velocity, corrected, runtime.surface_kind, runtime.surface_side, - runtime.maximum_entry_delta, - ); - if !rejected.is_empty() { - runtime.candidate = None; - p.flags_2468 |= 0x0004_0000; - for reason in rejected { skater.wipeout.state.request(reason, 0.0); } - return; - } - runtime.entry_velocity = Some(corrected); - } - let (name, scoring_name) = - runtime - .chromosome - .update(pose, kind, candidate.centre, direction, normal); - runtime.name = name.into(); - runtime.scoring_name = scoring_name.into(); -} -pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - physics.grind.active = matches!( - skater.player_state.current(), - PhysicalStateId::GrindFiftyFifty - | PhysicalStateId::GrindBoardslide - | PhysicalStateId::GrindTipslide - | PhysicalStateId::GrindFiveO - | PhysicalStateId::GrindBackslash - | PhysicalStateId::GrindDarkslide - ); - physics.grind.exiting = false; - physics.grind.release = grind_forces::Release::default(); - physics.grind.distance = 0.0; - physics.grind.previous_position = super::solve::deck_frame(&physics.board)[3]; - skater.ground_lifecycle.skeleton_elapsed_16505 = true; - physics - .board - .hook_mut() - .drive - .disable_animation(&mut skater.ground_lifecycle.board_animated_290); - skater.air_reckoning.state.reset_spin(); - if physics.grind.active { - bevy::log::info!( - "Grind entered: {} rail={:?}", - physics.grind.name, - physics - .grind - .candidate - .map(|c| physics.grind.primitives[c.primitive].owner) - ); - } - Ok(()) -} -pub(super) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - physics.grind.active = false; - physics - .board - .hook_mut() - .drive - .disable_animation(&mut skater.ground_lifecycle.board_animated_290); - //82D3F7D0 restores all four wheel drags to zero. - for body in &mut physics.board.bodies_mut()[..4] { - body.inertia.linear_drag = 0.0; - } - Ok(()) -} -pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - physics.grind.launched = false; - let board = super::solve::deck_frame(&physics.board); - let p = &skater.player_input.processed; - if physics.grind.active { - //82D40D90; Boardslide82D41788 only exposes this exit on kind2. - let explicit = p.flags_2476 & 0x2000_0000 != 0 - && (!matches!(physics.grind.kind, 1 | 5) || physics.grind.surface_kind == 2); - let was_leaving = physics.grind.release.leaving; - physics.grind.release.begin_update(p.scalar_2652, explicit); - if !was_leaving && physics.grind.release.leaving { - bevy::log::info!("GRIND_RELEASE kind={} speed={} explicit={}", physics.grind.kind, p.scalar_2652, explicit); - } - if physics.grind.release.finish_update() { - //82D3F638: byte37/value124, using the retained request owner. - skater.wipeout.state.request(17, 0.0); - } - } - if physics.grind.active && p.flags_2468 & 0x00400000 != 0 { - let c = physics - .grind - .candidate - .ok_or("Grind pop has no primitive")?; - let heights = if physics.grind.kind == 1 { - &physics.grind.boardslide_pop_heights - } else if physics.grind.kind == 2 { - &physics.grind.tipslide_pop_heights - } else { - &physics.grind.pop_heights - }; - let [min, max] = heights[p.state_variant_index_2528 as usize]; - let strength = skater.animation_input.extra.jump_strength; - let velocity = grind_forces::fifty_fifty_pop( - p.vectors_400_416[0].map(f32::from_bits), - grind_contact::upright_normal(c.direction), - c.direction, - board[3], - c.centre, - (1.0 - strength).mul_add(min, max * strength), - skater.animation_input.extra.grind_stability_nudge, - physics.grind.manager_age, - ); - physics.grind.launched = true; - physics.grind.launch_velocity = velocity; - physics.grind.cooldown = 10; - physics.grind.manager_age = (physics.grind.manager_age - 0.2).max(0.0); - skater - .ground_runtime - .set_animated_velocity(&mut physics.board, velocity); - physics - .board - .hook_mut() - .drive - .disable_animation(&mut skater.ground_lifecycle.board_animated_290); - return super::input_phase::update_grind_jump(physics, skater); - } - if physics.grind.active { - if let Some(velocity) = physics.grind.entry_velocity.take() { - //82D40AF8 ->82C04168: all seven bodies, preserving angular rates. - bevy::log::info!("GRIND_ENTRY board_velocity={:?} airborne_velocity={:?} applied_velocity={:?}", - p.vectors_400_416[0].map(f32::from_bits), - p.vectors_544_560_592_608[3].map(f32::from_bits), velocity); - skater.ground_runtime.set_animated_velocity(&mut physics.board, velocity); - //82D40CF8 also supplies Skeleton16112/16416 for this same tick. - skater.animated_skeleton.roots.supplied_prediction = Some( - core::array::from_fn(|i| velocity[i].mul_add(p.timestep_2604, board[3][i]))); - //The native entry-velocity branch bypasses the ordinary force - //update on this tick; its Ground skeleton continuation still runs. - let contact = physics.grind.candidate.ok_or("Grind entry lost its contact")?; - let normal = if physics.grind.kind == 0 { - grind_contact::upright_normal(contact.direction) - } else { physics.grind.surface_normal }; - skater.ground.steering.targets = [0.0; 2]; - physics.riding.update_grind_reckoning( - &mut skater.air_reckoning.state, normal, board[2], p.flags_2468, 0.9); - return super::input_phase::update_ground(physics, skater); - } - } - let velocity = p.vectors_400_416[0].map(f32::from_bits); - //Both state vtables neutralize the trucks before the shared board solve. - skater.ground.steering.targets = [0.; 2]; - let normal = if physics.grind.active { - let c = physics - .grind - .candidate - .ok_or("Active grind lost its candidate before selection")?; - let normal = if physics.grind.kind != 0 { - physics.grind.surface_normal - } else { - grind_contact::upright_normal(c.direction) - }; - physics.grind.crouch = dot3(board[2], normal).abs() * 0.4; - if physics.grind.release.leaving { - let surface = physics.grind.surface_kind; - let (strength, limit, lift, force_across) = match physics.grind.kind { - //50-50 exit82D41DF0, 5-O exit82D428C0. - 0 => if surface < 2 { (100.0, 1.0, 125.0, false) } else { (10.0, 1.0, 0.0, false) }, - 3 => if surface < 2 { (120.0, 0.9, 125.0, false) } else { (10.0, 0.9, 0.0, false) }, - //Static rail Tipslide82D420A0 and Backslash82D42A80. - 2 => (100.0, 1.5, 0.0, true), - 4 => (100.0, 0.75, if surface == 1 { 125.0 } else { 0.0 }, surface == 1), - //Boardslide82D41250 enters the existing runout/wipeout path - //on rail/ledge engagement; kind2 uses a physical side force. - _ => (90.0, 0.75, 0.0, false), - }; - if matches!(physics.grind.kind, 1 | 5) && surface != 2 { - skater.wipeout.state.request(10, 0.0); - } else { - append(physics, grind_forces::release_force( - board[3], c.centre, c.direction, normal, velocity, surface, - physics.grind.surface_side, force_across, strength, limit, lift, - ))?; - } - physics.riding.update_grind_reckoning( - &mut skater.air_reckoning.state, normal, board[2], p.flags_2468, 0.9); - return super::input_phase::update_ground(physics, skater); - } - let across = cross(c.direction, normal); - physics.grind.control.update( - physics.grind.kind, - board[2], - normal, - physics.grind.front_contact, - p.flags_2468 & 0x0010_0000 != 0, - false, - skater.animation_input.extra.grind_translation, - skater.animation_input.extra.grind_stability_nudge, - skater.animation_input.extra.grind_up_down, - skater.animation_input.extra.grind_grab_min_height, - ); - if matches!(physics.grind.kind, 1 | 5) { - physics.grind.exiting |= p.flags_2468 & 0x0020_0000 != 0; - let mass = skater - .player_input - .toolkit - .as_ref() - .ok_or("Boardslide needs board mass")? - .total_mass; - for force in grind_forces::boardslide_control( - board[3], - c.centre, - c.direction, - normal, - velocity, - //Darkslide82D41438 scales translation40 by remaining .16m - //deck width; Boardslide82D419A0 uses constant25. - skater.animation_input.extra.grind_translation - * if physics.grind.kind == 5 { - (1.0 - dot3(sub(board[3], c.centre), across).abs() * 6.25) * 40.0 / 25.0 - } else { - 1.0 - }, - mass, - physics.grind.exiting, - physics.grind.surface_kind == 2, - ) { - append(physics, force)?; - } - } else if physics.grind.kind == 2 { - //82D42390: lateral direction points toward the contact. The - //animation nudge changes sign with the contact side. - let offset = sub(board[3], c.centre); - let inward = across.map(|v| { - v * if dot3(across, offset) > 0.0 { - -1.0 - } else { - 1.0 - } - }); - let nudge = skater.animation_input.extra.grind_stability_nudge - * if dot3(normal, cross(c.direction, offset)) > 0.0 { - 1.0 - } else { - -1.0 - }; - append(physics, inward.map(|v| v * nudge * 37.0))?; - } else { - let (strength, forward_offset, up_offset) = match physics.grind.kind { - 3 => (3000.0, 0.234, 0.07), - 4 => (3000.0, 0.39, 0.025), - _ => (800.0, 0.0, 0.07), - }; - let force = grind_forces::lateral_pin( - board, - c.centre, - across, - velocity, - strength, - forward_offset, - up_offset, - physics.grind.front_contact, - physics.grind.pin_vs_slope.evaluate(dot3(normal, normal)), - ); - append(physics, force)?; - if matches!(physics.grind.kind, 0 | 3) { - //82D401F8: support load from the native pitch conditioner. - append( - physics, - normal.map(|v| v * physics.grind.control.pitch.abs() * -600.0), - )?; - } - } - //The primitive support frame is static and level for the authored rails. - //82D86318 publishes this unit support normal in candidate304. - let friction = grind_forces::friction( - velocity, - normal, - normal, - skater.player_input.grind.friction_vs_time, - false, - 1.3, - 0, - match physics.grind.kind { - 1 | 5 => [55., 55., 60.], - 2 => [60., 60., 60.], - 4 => [70., 80., 80.], - _ => [50., 40., 40.], - }, - ); - append(physics, friction)?; - let forward = if dot3(board[2], c.direction) > 0.0 { - c.direction - } else { - c.direction.map(|x| -x) - }; - let target = if matches!(physics.grind.kind, 1 | 5) { - //82D418B8 aligns the board's RIGHT axis with the rail;50-50 - //82D41E38 aligns its FORWARD axis instead. - let right = if dot3(board[0], c.direction) > 0.0 { - c.direction - } else { - c.direction.map(|x| -x) - }; - let up = if physics.grind.kind == 5 { - normal.map(|v| -v) - } else { - normal - }; - [right, up, cross(right, up), board[3]] - } else if matches!(physics.grind.kind, 2 | 4) { - grind_contact::control::tip_frame( - board, - c.direction, - normal, - c.centre, - physics.grind.kind == 4, - ) - } else if physics.grind.kind == 3 - || (physics.grind.kind == 0 && physics.grind.control.pitch.abs() > 0.12) - { - grind_contact::control::truck_frame( - board, - normal, - &physics.grind.control, - p.flags_2468 & 0x0010_0000 != 0, - ) - } else { - [cross(normal, forward), normal, forward, board[3]] - }; - //82D40890 uses constant0.1 for interpolation; its f1 parameter is - //separate random perturbation amplitude, not the interpolation weight. - let (frame, _) = skate_core::animation::foot_ik::interpolate_native(&board, &target, 0.1); - physics.board.set_hook_transform(RetailAffineTransform { - basis: Basis3 { - columns: std::array::from_fn(|i| [frame[i][0], frame[i][1], frame[i][2]]), - }, - translation: xyz(frame[3]), - }); - physics - .board - .hook_mut() - .drive - .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); - physics.grind.distance += - dot3(sub(board[3], physics.grind.previous_position), c.direction).abs(); - physics.grind.previous_position = board[3]; - normal - } else { - //Nonspecific82D42F50 applies the native -40 support force while the - //selector waits for actual wheel/collision observations after rail exit. - append( - physics, - p.vectors_544_560_592_608[0] - .map(f32::from_bits) - .map(|v| v * -40.0), - )?; - board[1] - }; - physics.riding.update_grind_reckoning( - &mut skater.air_reckoning.state, - normal, - board[2], - skater.player_input.processed.flags_2468, - if physics.grind.active { 0.9 } else { 0.98 }, - ); - //82D40D40 and the ordinary nonspecific branch both call Skeleton82BDF530. - super::input_phase::update_ground(physics, skater) -} -fn append(physics: &mut GamePhysics, force: V) -> Result<(), String> { - if !physics.board.forces_mut().append(QueuedPointForce { - tag: 0, - force_world: xyz(force), - point_body: Vector3::ZERO, - }) { - return Err("Grind force queue exhausted".into()); - } - Ok(()) +pub(crate) use observation::ManagerObservation; + +/// Physical/output family, NOT the seven-valued admission kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub(crate) enum Family { + FiftyFifty = 0, + Boardslide = 1, + Tipslide = 2, + FiveO = 3, + Backslash = 4, + Darkslide = 5, } -fn xyz(v: V) -> Vector3 { - Vector3::new(v[0], v[1], v[2]) -} -fn sub(a: V, b: V) -> V { - std::array::from_fn(|i| a[i] - b[i]) -} -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., - ] +impl Family { + pub fn from_physical_state(state: u32) -> Option { + Some(match state { + 400 => Self::Boardslide, + 401 => Self::FiftyFifty, + 402 => Self::Tipslide, + 403 => Self::FiveO, + 404 => Self::Backslash, + 405 => Self::Darkslide, + _ => return None, + }) + } + pub fn physical_state(self) -> u32 { + match self { + Self::Boardslide => 400, + Self::FiftyFifty => 401, + Self::Tipslide => 402, + Self::FiveO => 403, + Self::Backslash => 404, + Self::Darkslide => 405, + } + } } diff --git a/crates/skate-game/src/physics/grind/acquisition.rs b/crates/skate-game/src/physics/grind/acquisition.rs new file mode 100644 index 00000000..349b6e5c --- /dev/null +++ b/crates/skate-game/src/physics/grind/acquisition.rs @@ -0,0 +1,13 @@ +//! Coordinator boundary only. No copied ZIP query, admission or family priority. +use skate_core::physics::grind_contact::{Primitive, families::Contact}; + +/// The investigator owns admission, state priority and fallback proximity. +/// Publish this only for an admitted physical contact; a proximity-only result +/// stays with the manager and must not be converted into an active grind here. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AcquiredContact { + /// Includes the manager's seven-valued entry_kind, independently of kind. + pub contact: Contact, + /// Exact winning primitive endpoints/owner, not fabricated truck endpoints. + pub primitive: Primitive, +} diff --git a/crates/skate-game/src/physics/grind/advance.rs b/crates/skate-game/src/physics/grind/advance.rs new file mode 100644 index 00000000..7b968ed2 --- /dev/null +++ b/crates/skate-game/src/physics/grind/advance.rs @@ -0,0 +1,136 @@ +//! Native common82D3F4E8 and Nonspecific82D42DF0 Update order. +use super::super::{GamePhysics, SkaterRuntime}; +use super::Family; +use super::arithmetic::dot3; +use skate_core::physics::grind_forces::reckoning; +type V = [f32; 4]; + +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + if skater.grind.nonspecific_active { + super::substate::execute_nonspecific(physics, skater)?; + let frame = skater + .player_input + .toolkit + .as_ref() + .ok_or("701 requires BoardToolkit")? + .deck; + //82F825D0/F0 initialize830BD320/+4A0 from8231A844(+1)/8216DEE0(-1). + let sign = if skater.player_input.processed.flags_2484 & 0x0020_0000 != 0 { + -1. + } else { + 1. + }; + reckon(physics, skater, frame[1].map(|x| x * sign), frame[2], 0.98); + return Ok(()); + } + let family = skater + .grind + .active + .ok_or("Grind Update requires active family")?; + let manager = skater + .grind + .manager + .ok_or("Grind Update requires manager observation")?; + let frame = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind Update requires BoardToolkit")? + .deck; + let p = &skater.player_input.processed; + let speed = p.scalar_2652; + let index = family as usize; + let state = &mut skater.grind.states[index]; + state.output.just_jumped = false; + let tipped = state.output.tipslide_97_98_99[0]; + if !tipped { + state.output.direction = manager.geometry.direction_1136; + state.output.normal = manager.geometry.normal_1152; + state.output.across = cross(state.output.direction, state.output.normal); + } + let normal = if tipped { + frame[1] + } else { + manager.geometry.target_up_1168 + }; + let smoothing = if !tipped && manager.surface.reckon_blend_selector_1500 > 0. { + 0.9 + } else { + 0.7 + }; + reckon(physics, skater, normal, frame[2], smoothing); + let state = &mut skater.grind.states[index]; + // Common UserTriggeredExit82D40D90 is processed2476 bit29. Slides' + //82D41788 applies it only to ledges (kind2), preserving the native gate. + let user_exit = skater.player_input.processed.flags_2476 & 0x2000_0000 != 0 + && (!matches!(family, Family::Boardslide | Family::Darkslide) + || manager.geometry.kind_1464 == 2); + let should_exit = (manager.engagement.kind_1248 == 2 + && (manager.geometry.flags_1476 & 0x0800_0000 != 0 + || manager.control.flags_2488 & 0x1000_0000 != 0) + && speed < 1.) + || (manager.control.flags_1516 as i32) < 0 + || user_exit + || (state.updates > 60 && speed < 0.15); + if state.output.substate == 1 && should_exit { + state.output.substate = 2; + } + super::substate::execute(physics, skater, &manager)?; + let state = &mut skater.grind.states[index]; + state.leaving_updates = if state.output.substate == 2 { + state.leaving_updates.wrapping_add(1) + } else { + 0 + }; + if state.leaving_updates > 35 { + skater.wipeout.state.request(17, 0.); + } + state.output.crouch = dot3(frame[2], state.output.normal).abs() * 0.4; + let p = &skater.player_input.processed; + skater.ground.steering.update( + 0., + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + let velocity = p.vectors_400_416[0].map(f32::from_bits); + skater.skeleton_input.head_tracking_history[5] = core::array::from_fn(|i| { + velocity[i].mul_add( + skater.grind.settings.look_ahead, + manager.geometry.point_1120[i], + ) + }); + //82D3F614 sets r4=1; steering leaf preserves it to the82D3F6E0 store. + skater.skeleton_input.head_tracking_active = true; + state.updates = state.updates.wrapping_add(1); + Ok(()) +} + +fn reckon( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + normal: V, + heading: V, + smoothing: f32, +) { + reckoning::update( + &mut physics.riding.reckoning, + &mut physics.riding.reckoning_frames, + &mut physics.riding.body_spin, + &mut skater.air_reckoning.state, + &skater.grind.settings.reckoning, + normal, + heading, + smoothing, + skater.player_input.processed.flags_2468 & 0x0010_0000 != 0, + 0., + ); +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] +} diff --git a/crates/skate-game/src/physics/grind/arithmetic.rs b/crates/skate-game/src/physics/grind/arithmetic.rs new file mode 100644 index 00000000..64deaa6a --- /dev/null +++ b/crates/skate-game/src/physics/grind/arithmetic.rs @@ -0,0 +1,4 @@ +//! Same scalar dot ordering as CURRENT core's private native arithmetic helper. +pub(super) fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} diff --git a/crates/skate-game/src/physics/grind/board.rs b/crates/skate-game/src/physics/grind/board.rs new file mode 100644 index 00000000..0a1852f1 --- /dev/null +++ b/crates/skate-game/src/physics/grind/board.rs @@ -0,0 +1,48 @@ +//! Concrete CURRENT BoardRuntime adaptation of AddWorldForce82C03F98. +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + point_force::{RetailForceAccumulator, accumulate_point_force}, + }, +}; + +/// Immediate deck acceleration/torque accumulation. Unlike queued board forces, +/// this has no capacity, attribution tag or stock force-point Y offset. +pub(crate) fn apply_world_force(board: &mut BoardRuntime, force: [f32; 4], point: [f32; 4]) { + let deck = &mut board.bodies_mut()[BodyId::Deck.index()]; + apply_to_deck(deck, force, point); +} + +pub(super) fn apply_to_deck( + deck: &mut skate_core::physics::assembly::BodySnapshot, + force: [f32; 4], + point: [f32; 4], +) { + let rates = &mut deck.rates; + let arm = Vector3::new( + point[0] - rates.position.x, + point[1] - rates.position.y, + point[2] - rates.position.z, + ); + // The shared helper's input is already world-space here, so its basis is + // identity. Preserve its native cross/tensor accumulation order. + let next = accumulate_point_force( + RetailForceAccumulator { + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + cool_down: rates.cool_down, + }, + Vector3::new(force[0], force[1], force[2]), + arm, + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + deck.inertia.inverse_mass, + rates.world_inverse_inertia, + ); + rates.force_acceleration = next.force_acceleration; + rates.torque_acceleration = next.torque_acceleration; + rates.cool_down = next.cool_down; +} diff --git a/crates/skate-game/src/physics/grind/condition.rs b/crates/skate-game/src/physics/grind/condition.rs new file mode 100644 index 00000000..45d59334 --- /dev/null +++ b/crates/skate-game/src/physics/grind/condition.rs @@ -0,0 +1,71 @@ +//! Completed PhysOut -> chromosome82DEE508, before filtered82DE56B0. +use super::{ + super::{GamePhysics, SkaterRuntime, grind_chromosome::Input}, + Family, +}; +use skate_core::physics::board::BodyId; + +/// Run on EVERY completed publication, not merely physical grind frames. +///82DF2130 installs chromosome at slot1 and filtered at slot5;82DF2710 +///dispatches in ascending order. Board/Skeleton/state Fill must precede this. +pub(crate) fn condition(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let out = &mut skater.player_input.physical.grinds; + //Common82DB7948..7968 derives this after state Fill, not from category400. + out.grinding_316 = u8::from(matches!(out.words_136_140[1], 1 | 2)); + let family = match out.words_136_140[0] { + 0 => Some(Family::FiftyFifty), + 1 => Some(Family::Boardslide), + 2 => Some(Family::Tipslide), + 3 => Some(Family::FiveO), + 4 => Some(Family::Backslash), + 5 => Some(Family::Darkslide), + //The reset template uses u32::MAX. No family is normal outside grind; + //history and previous-category bookkeeping must still run this frame. + _ => None, + }; + if out.grinding_316 != 0 && family.is_none() { + return Err(format!( + "Active grind has invalid native family {}", + out.words_136_140[0] + )); + } + //Board Fill82C02AD8/B14 copies the unadjusted part transform. Its effective + //variant82C01BF8 supplies Basic96 and128; neither is the Skeleton forward. + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let axis = |a: [f32; 3]| [a[0], a[1], a[2], 0.0]; + let effective_forward = axis(physics.riding.motion.effective_basis.columns[2]); + //Basic144 comes from the dynamics COM at82C02D0C, not part translation48. + let com = physics.board.bodies()[BodyId::Deck.index()].rates.position; + let raw = |v: [u32; 4]| v.map(f32::from_bits); + let input = Input { + category: skater.player_input.physical.state.category_12, + grinding_316: out.grinding_316 != 0, + //Common82DB7480 copies Processed2468 bit21 to Air439 on every frame. + air_event_439: skater.player_input.processed.flags_2468 & 0x0020_0000 != 0, + family, + basic_right_0: axis(deck.basis.columns[0]), + basic_forward_32: axis(deck.basis.columns[2]), + basic_position_48: [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ], + basic_location_axis_96: effective_forward, + basic_twist_axis_128: effective_forward, + basic_location_position_144: [com.x, com.y, com.z, 0.0], + //82BE1D00..1E58: physical pose starts8016;9232=part19,8976=part15. + //256/272 receive column0, not toe positions or IK/rendered bones. + feet_256_272: [ + skater.skeleton.record.pose[19][0], + skater.skeleton.record.pose[15][0], + ], + //Common82DB7A90 copies the completed animation packet's byte10372. + fakie_155: skater.animation.packet.riding_fakie, + point: raw(out.point_16), + direction: raw(out.direction_0), + normal: raw(out.normal_32), + across: raw(out.across_48), + }; + skater.grind.condition_outputs(input, out) +} diff --git a/crates/skate-game/src/physics/grind/contact.rs b/crates/skate-game/src/physics/grind/contact.rs new file mode 100644 index 00000000..759c2a7e --- /dev/null +++ b/crates/skate-game/src/physics/grind/contact.rs @@ -0,0 +1,266 @@ +//! Six native substate1 dispatches. Manager query/family selection is upstream. +use super::super::{GamePhysics, SkaterRuntime}; +use super::arithmetic::dot3; +use super::{Family, ManagerObservation, board::apply_world_force, forces, lifecycle}; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + drive_frames::RetailAffineTransform, + grind_forces::{noise, orientation, slide, support}, + }, +}; +type V = [f32; 4]; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + manager: &ManagerObservation, +) -> Result<(), String> { + let family = skater + .grind + .active + .ok_or("Grind contact requires active family")?; + let toolkit = *skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind contact requires board toolkit")?; + let board_frame = toolkit.deck; + let p = &skater.player_input.processed; + let velocity = p.vectors_400_416[0].map(f32::from_bits); + let mut state = skater.grind.states[family as usize]; + let position = board_frame[3]; + let normal = state.output.normal; + let across = state.output.across; + let direction = state.output.direction; + let mut operations = Vec::with_capacity(8); + // Preserve the original order, including force calls surrounding orientation. + use Op::*; + match family { + Family::Darkslide | Family::Boardslide => operations.extend([ + Orient, + Translate, + Pin, + Friction([55., 55., 60.]), + Coping, + ExitLean, + Wheels, + ]), + Family::FiftyFifty => operations.extend([ + Friction([50., 40., 40.]), + Align(800., 0., 0.07), + Coping, + Pin, + ExitLean, + Orient, + ]), + Family::Tipslide => operations.extend([ + Orient, + TipStability, + Friction([60.; 3]), + Coping, + Pin, + ExitLean, + Wheels, + ]), + Family::FiveO => operations.extend([ + Orient, + Align(3000., 0.234, 0.07), + Pin, + Friction([50., 40., 40.]), + Coping, + ExitLean, + ]), + Family::Backslash => operations.extend([ + Orient, + Align(3000., 0.39, 0.025), + Friction([70., 80., 80.]), + Coping, + Pin, + ExitLean, + Wheels, + ]), + } + for operation in operations { + match operation { + Orient => { + let target = orientation::target( + family as u32, + orientation::Input { + board: board_frame, + direction, + normal, + point: manager.geometry.point_1120, + yaw_1504: p.grind.yaw_1504, + pitch_1508: p.grind.pitch_1508, + switched: p.flags_2468 & 0x0010_0000 != 0, + }, + )?; + //82D40890 blends from the current physical board, not the + //preceding target;82D40A98 adds noise after that blend. + let mut target_frame = orientation::blend(board_frame, target.frame); + if target.noise_amount > 0. { + let draws = skater.grind.take_orientation_noise(); + target_frame = + noise::apply(target_frame, p.scalar_2652, target.noise_amount, draws); + } + state.frame = target_frame; + physics.board.set_hook_transform(RetailAffineTransform { + basis: Basis3 { + columns: core::array::from_fn(|i| { + [state.frame[i][0], state.frame[i][1], state.frame[i][2]] + }), + }, + translation: Vector3::new( + state.frame[3][0], + state.frame[3][1], + state.frame[3][2], + ), + }); + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); + if matches!(family, Family::FiveO | Family::FiftyFifty) { + apply_world_force( + &mut physics.board, + support::truck_compensation(normal, p.grind.pitch_1508), + position, + ); + } + } + Friction(strengths) => forces::friction( + &mut physics.board, + manager, + position, + velocity, + normal, + strengths, + ), + Align(strength, forward, up) => forces::lateral_pin( + &mut physics.board, + manager, + board_frame, + across, + normal, + velocity, + strength, + forward, + up, + &skater.grind.settings.pin_vs_slope, + ), + Pin => { + if let Some(f) = support::pin(normal, manager.surface.gravity_relief_1512) { + apply_world_force(&mut physics.board, f, position); + } + } + Coping => { + if let Some(f) = support::coping( + manager.geometry.upmost_normal_1408, + velocity, + manager.surface.gravity_relief_1512, + ) { + apply_world_force(&mut physics.board, f, position); + } + } + ExitLean => { + if let Some(f) = support::exit_lean( + manager.geometry.high_side_1440, + manager.surface.reckon_blend_selector_1500, + &skater.grind.settings.exit_assist, + ) { + apply_world_force(&mut physics.board, f, position); + } + } + Wheels => lifecycle::manage_wheel_spin( + &mut physics.board, + core::array::from_fn(|i| physics.riding.ground.parts[i].in_contact), + ), + Translate => { + state.preparing_jump |= p.flags_2468 & 0x0020_0000 != 0; + forces::slide( + &mut physics.board, + if family == Family::Darkslide { + slide::Slide::Darkslide + } else { + slide::Slide::Boardslide + }, + slide::Input { + position, + point: manager.geometry.point_1120, + direction, + across, + velocity, + translation_2796: manager.control.translation_2796, + total_mass_2660: toolkit.total_mass, + update_frequency: physics.settings.step.simulation.frequency, + preparing_jump: state.preparing_jump, + geometry_kind: manager.geometry.kind_1464, + }, + ); + } + TipStability => { + let offset = sub(position, manager.geometry.point_1120); + let inward = scale(across, if dot3(across, offset) > 0. { -1. } else { 1. }); + let amount = if manager.control.flags_1516 & 0x0200_0000 != 0 { + 39. + } else { + (if dot3(normal, cross(direction, offset)) > 0. { + manager.control.balance_2800 + } else { + -manager.control.balance_2800 + }) * 37. + }; + apply_world_force(&mut physics.board, scale(inward, amount), position); + } + } + } + if family == Family::Darkslide { + // Darkslide contact82D41194..1234 alone writes classification104 here. + state.output.classification_104 = if manager.geometry.kind_1464 == 2 { + if dot3(manager.geometry.high_side_1440, toolkit.effective[2]) > 0. { + 2 + } else { + 1 + } + } else { + let perpendicular = scale( + across, + dot3(sub(position, manager.geometry.point_1120), across), + ); + if dot3(perpendicular, toolkit.effective[2]) > 0. { + 1 + } else { + 2 + } + }; + } + skater.grind.states[family as usize] = state; + Ok(()) +} +enum Op { + Orient, + Translate, + Pin, + Friction([f32; 3]), + Coping, + ExitLean, + Wheels, + Align(f32, f32, f32), + TipStability, +} +fn scale(v: V, s: f32) -> V { + v.map(|x| x * s) +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] +} diff --git a/crates/skate-game/src/physics/grind/forces.rs b/crates/skate-game/src/physics/grind/forces.rs new file mode 100644 index 00000000..2e67a969 --- /dev/null +++ b/crates/skate-game/src/physics/grind/forces.rs @@ -0,0 +1,83 @@ +//! Original force leaves wired to CURRENT's immediate deck accumulator. +use super::{ManagerObservation, board::apply_world_force}; +use skate_core::{ + physics::{board_runtime::BoardRuntime, grind_forces}, + point_graph::PointGraph, +}; +type V = [f32; 4]; + +///82D3FD88. The family owns its three strengths and position/velocity snapshot. +pub(crate) fn friction( + board: &mut BoardRuntime, + manager: &ManagerObservation, + position: V, + velocity: V, + normal: V, + strengths: [f32; 3], +) { + let force = grind_forces::friction( + velocity, + normal, + manager.geometry.upmost_normal_1408, + manager.surface.friction_vs_time_1496, + manager.geometry.flags_1476 & 0x4000_0000 != 0, + grind_forces::material_multiplier(manager.surface.material_1472), + manager.geometry.kind_1464, + strengths, + ); + // Native skips AddForce for speed<=.001, not an unconditional zero-force + // call (which would still wake the body). The leaf exposes that separately. + if grind_forces::friction_applies(velocity, normal) { + apply_world_force(board, force, position); + } +} + +pub(crate) fn lateral_pin( + board: &mut BoardRuntime, + manager: &ManagerObservation, + frame: [V; 4], + across: V, + normal: V, + velocity: V, + strength: f32, + forward_offset: f32, + up_offset: f32, + pin_vs_slope: &PointGraph<4>, +) { + let slope = grind_forces::pin_slope( + normal, + manager.geometry.upmost_normal_1408, + manager.surface.gravity_relief_1512, + pin_vs_slope, + ); + let force = grind_forces::lateral_pin( + frame, + manager.geometry.point_1120, + across, + velocity, + strength, + forward_offset, + up_offset, + manager.control.flags_1516 & 0x2000_0000 != 0, + slope, + ); + if let Some(force) = force { + apply_world_force(board, force, frame[3]); + } +} + +pub(crate) fn slide( + board: &mut BoardRuntime, + family: grind_forces::slide::Slide, + input: grind_forces::slide::Input, +) { + for force in grind_forces::slide::control(family, input) { + apply_world_force(board, force, input.position); + } +} + +pub(crate) fn release(board: &mut BoardRuntime, input: grind_forces::release::Input) { + if let Some(force) = grind_forces::release::force(input) { + apply_world_force(board, force, input.position); + } +} diff --git a/crates/skate-game/src/physics/grind/involuntary.rs b/crates/skate-game/src/physics/grind/involuntary.rs new file mode 100644 index 00000000..08e705e8 --- /dev/null +++ b/crates/skate-game/src/physics/grind/involuntary.rs @@ -0,0 +1,113 @@ +//! Native family substate2 dispatch; no generic tipslide substitution. +use super::super::{GamePhysics, SkaterRuntime}; +use super::arithmetic::dot3; +use super::{Family, ManagerObservation, forces, lifecycle}; +use skate_core::physics::grind_forces::release::Input; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + manager: &ManagerObservation, +) -> Result<(), String> { + let family = skater + .grind + .active + .ok_or("Grind exit requires active family")?; + let frame = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind exit requires BoardToolkit")? + .deck; + let state = &mut skater.grind.states[family as usize]; + let kind = manager.geometry.kind_1464; + let mut input = Input { + position: frame[3], + point: manager.geometry.point_1120, + across: state.output.across, + normal: state.output.normal, + velocity: skater.player_input.processed.vectors_400_416[0].map(f32::from_bits), + geometry_kind: kind, + high_side: manager.geometry.high_side_1440, + force_across: false, + enable_lift: true, + strength: 100., + speed_limit: 1., + lift: 125., + }; + match family { + Family::Boardslide | Family::Darkslide => { + //82D41250: rail branches request10 and publish direction*136 to + //Wipeout16; only the ledge branch applies the lateral exit force. + if kind == 2 { + input.strength = 90.; + input.speed_limit = 0.75; + input.enable_lift = false; + forces::release(&mut physics.board, input); + } else { + state.output.slide_wipeout = true; + state.output.slide_impulse = manager.geometry.direction_1136.map(|x| x * 136.); + skater.wipeout.state.request(10, 0.); + } + lifecycle::clear_wheel_spin(&mut physics.board); + } + Family::FiftyFifty => { + if kind >= 2 { + input.strength = 10.; + input.lift = 0.; + } + forces::release(&mut physics.board, input); + } + Family::FiveO => { + input.strength = if kind < 2 { 120. } else { 10. }; + input.lift = if kind < 2 { 125. } else { 0. }; + input.speed_limit = 0.9; + // Raw82D42948/4C passes computed force-across in r8 and lift in r7. + input.force_across = kind == 1 && dot3(frame[2], input.across).abs() > 0.23; + forces::release(&mut physics.board, input); + } + Family::Backslash => { + input.speed_limit = 0.75; + input.force_across = kind == 1; + input.enable_lift = kind == 1; + forces::release(&mut physics.board, input); + lifecycle::clear_wheel_spin(&mut physics.board); + } + Family::Tipslide => { + lifecycle::clear_wheel_spin(&mut physics.board); + if manager.geometry.flags_1476 & 0x0800_0000 != 0 || state.output.tipslide_97_98_99[0] { + if !state.output.tipslide_97_98_99[0] { + physics + .board + .hook_mut() + .drive + .disable_animation(&mut skater.ground_lifecycle.board_animated_290); + state.output.tipslide_97_98_99[0] = true; + state.output.tipslide_97_98_99[2] = true; + } + super::tip_nudge::apply( + &mut physics.board, + input.position, + input.point, + input.across, + )?; + } else { + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut skater.ground_lifecycle.board_animated_290); + input.speed_limit = 1.5; + //82D420EC/F0: r8=1 (across), r7=0 (no normal lift). + input.force_across = true; + input.enable_lift = false; + forces::release(&mut physics.board, input); + state.output.tipslide_97_98_99[1] = true; + if manager.control.flags_2488 & 0x1000_0000 != 0 { + state.output.tipslide_97_98_99[2] = true; + } + } + } + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/grind/launch.rs b/crates/skate-game/src/physics/grind/launch.rs new file mode 100644 index 00000000..b5932c2b --- /dev/null +++ b/crates/skate-game/src/physics/grind/launch.rs @@ -0,0 +1,27 @@ +//! Concrete SetVelocity82C04168 continuation; retain main's jumper owner. +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::BoardRuntime, + grind_contact::manager::Jumper, + grind_forces::launch::{self, Input}, + }, +}; + +pub(crate) fn apply( + board: &mut BoardRuntime, + jumper: &mut Jumper, + flags_2476: &mut u32, + input: Input, +) -> [f32; 4] { + let output = launch::launch(*jumper, input); + let v = output.velocity; + // Original SetVelocity writes every board part, not only the deck. It + // neither resets angular velocity nor teleports the board's transforms. + for body in board.bodies_mut() { + body.rates.linear_velocity = Vector3::new(v[0], v[1], v[2]); + } + *jumper = output.jumper; + *flags_2476 |= output.flags_2476_to_or; + v +} diff --git a/crates/skate-game/src/physics/grind/lifecycle.rs b/crates/skate-game/src/physics/grind/lifecycle.rs new file mode 100644 index 00000000..abf4ed2f --- /dev/null +++ b/crates/skate-game/src/physics/grind/lifecycle.rs @@ -0,0 +1,61 @@ +//! Physical side effects of Enter82D3F318/Exit82D3F430, all six families. +//! State latches/frame initialization are distinct from these body operations. +use skate_core::physics::{ + board::BodyId, board_runtime::BoardRuntime, skeleton_body::SkeletonCollisionMode, +}; +const FEET: [usize; 4] = [19, 15, 20, 16]; + +pub(crate) fn enter( + board: &mut BoardRuntime, + animated_290: &mut u8, + skeleton_16505: &mut bool, + collision: &mut SkeletonCollisionMode, +) { + board.hook_mut().drive.disable_animation(animated_290); + *skeleton_16505 = true; + //82D91330 disables these four volumes permanently (counter0), not the + //six-body/two-frame collision suppression used by KnownAir grind assistance. + for part in FEET { + collision.parts[part].volume_group = 4; + collision.parts[part].enabled = false; + collision.disable_count[part] = 0; + } + board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = f32::from_bits(0x4066_6665); + // No ResetSpin call exists in this Enter. +} + +pub(crate) fn exit( + board: &mut BoardRuntime, + animated_290: &mut u8, + collision: &mut SkeletonCollisionMode, + // BoneHasCollision values from CURRENT's stock part definitions in FEET order. + bone_has_collision: [bool; 4], + // Existing82C091F8 standard setting, already scaled by822F860C. + standard_angular_drag: f32, +) { + for (part, enabled) in FEET.into_iter().zip(bone_has_collision) { + collision.normal_bone(part, enabled); + } + board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = standard_angular_drag; + board.hook_mut().drive.disable_animation(animated_290); + clear_wheel_spin(board); +} + +///82D3F710. These are actual wheel collision bytes844..847. +pub(crate) fn manage_wheel_spin(board: &mut BoardRuntime, contact: [bool; 4]) { + for (wheel, contact) in board.bodies_mut()[..4].iter_mut().zip(contact) { + wheel.inertia.angular_drag = + (if contact { 0.5 } else { 0.0 }) * f32::from_bits(0x426f_ffff); + } +} + +///82D3F7D0/S2 82D85228 SetPartAngularDrag; ZIP's linear_drag write was wrong. +pub(crate) fn clear_wheel_spin(board: &mut BoardRuntime) { + for wheel in &mut board.bodies_mut()[..4] { + wheel.inertia.angular_drag = 0.0; + } +} diff --git a/crates/skate-game/src/physics/grind/observation.rs b/crates/skate-game/src/physics/grind/observation.rs new file mode 100644 index 00000000..8a7c3ca4 --- /dev/null +++ b/crates/skate-game/src/physics/grind/observation.rs @@ -0,0 +1,78 @@ +//! Required producer inputs. None implements Default: absence of a producer is +//! not permission to use world-up, metal friction, geometry zero or full energy. +use super::Family; +type V = [f32; 4]; + +/// Snapshot after the manager's geometry, material, assistance, balance, +/// engagement, controls and jumper phases. Production wiring belongs to main. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ManagerObservation { + pub geometry: GeometryObservation, + pub surface: SurfaceObservation, + pub control: ControlObservation, + pub engagement: EngagementObservation, + pub jumper: JumperObservation, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct GeometryObservation { + pub point_1120: V, + pub direction_1136: V, + pub normal_1152: V, + pub target_up_1168: V, + pub primitive_start_1264: V, + pub primitive_end_1280: V, + /// Native optional spline header contains TWO independent GUIDs. + pub spline_guids_1296: Option<[u64; 2]>, + pub upmost_normal_1408: V, + pub high_side_1440: V, + /// Retain every value; force leaves select 0, 1, or all other geometry. + pub kind_1464: u32, + pub flags_1476: u32, + pub impact_speed_1492: f32, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct SurfaceObservation { + /// Output/audio identifier is NOT the friction material identifier. + pub audio_surface_1468: u32, + pub material_1472: u32, + pub friction_vs_time_1496: f32, + pub reckon_blend_selector_1500: f32, + /// S3 gravity-relief producer82D8ACF0; NOT UpdateFrictionVsTime. + pub gravity_relief_1512: f32, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ControlObservation { + pub family: Family, + /// Signed bit31 forces exit; bit29 selects the front end; bit30 delivers + /// engagement velocity. Keep the source word rather than guessing booleans. + pub flags_1516: u32, + pub flags_2468: u32, + pub flags_2488: u32, + pub translation_2796: f32, + pub balance_2800: f32, + /// Original exit-lean producer's result, not derived from deck lean here. + pub exit_lean: f32, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct EngagementObservation { + pub velocity_1184: V, + pub kind_1248: u32, +} + +/// Jumper cache after82D739D8; invalid investigation retains its previous +/// geometry/normals. Do not reconstruct these from the current deck frame. +#[derive(Clone, Copy, Debug)] +pub(crate) struct JumperObservation { + pub geometry_kind_16: u32, + pub family_20: Family, + pub energy_24: f32, + pub high_side_32: V, + pub normal_48: V, + pub direction_64: V, + pub upmost_normal_80: V, + pub point_96: V, +} diff --git a/crates/skate-game/src/physics/grind/output.rs b/crates/skate-game/src/physics/grind/output.rs new file mode 100644 index 00000000..b6124a37 --- /dev/null +++ b/crates/skate-game/src/physics/grind/output.rs @@ -0,0 +1,160 @@ +//! Common82D40EA0 and six family FillOut overrides. Post/selector are separate. +use super::{Family, ManagerObservation}; +use skate_core::player::input_phase::GrindOutputFields; +type V = [f32; 4]; + +/// Retained physical-state fields, not reconstructed from filtered output. +#[derive(Clone, Copy, Debug)] +pub(crate) struct State { + pub direction: V, + pub normal: V, + pub across: V, + pub crouch: f32, + pub leaving: bool, + pub substate: u32, + pub classification_104: u32, + pub just_jumped: bool, + pub jump_velocity: V, + pub slide_wipeout: bool, + pub slide_impulse: V, + pub tipslide_97_98_99: [bool; 3], +} + +/// Fields owned by these FillOut methods. The caller retains this between +/// publications: unwritten optional fields must survive exactly as in S3. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PhysicalOutput { + pub direction: V, + pub point: V, + pub normal: V, + pub across: V, + pub primitive_endpoints: [V; 2], + pub high_side: V, + pub impact_speed: f32, + pub crouch: f32, + pub family: Family, + pub substate: u32, + pub audio_surface: u32, + pub spline_guids: [u64; 2], + pub classification: u32, + pub leaving: bool, + pub on_front: bool, + pub is_ledge: bool, + pub curb: bool, + pub tipslide_324_325_326: [bool; 3], +} + +/// Optional writes to OTHER physical output blocks. No inactive-event clearing +/// is performed here; the owner of Air/Wipeout output owns those resets. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SideEffects { + pub air_jump_velocity_128: Option, + pub wipeout_impulse_16: Option, +} + +pub(crate) fn fill( + family: Family, + state: State, + manager: &ManagerObservation, + output: &mut PhysicalOutput, +) -> SideEffects { + let geometry = manager.geometry; + output.crouch = state.crouch; + output.leaving = state.leaving; + output.direction = state.direction; + output.normal = state.normal; + output.across = state.across; + output.point = geometry.point_1120; + output.primitive_endpoints = [geometry.primitive_start_1264, geometry.primitive_end_1280]; + output.impact_speed = geometry.impact_speed_1492; + output.audio_surface = manager.surface.audio_surface_1468; + output.substate = state.substate; + output.is_ledge = geometry.kind_1464 == 2; + output.curb = geometry.flags_1476 & 0x4000_0000 != 0; + output.classification = state.classification_104; + if let Some(guids) = geometry.spline_guids_1296 { + output.spline_guids = guids; + } + if output.is_ledge { + output.high_side = geometry.high_side_1440; + } + output.family = family; + if matches!(family, Family::Tipslide | Family::FiveO | Family::Backslash) { + output.on_front = manager.control.flags_1516 & 0x2000_0000 != 0; + } + if family == Family::Tipslide { + output.tipslide_324_325_326 = state.tipslide_97_98_99; + } + SideEffects { + air_jump_velocity_128: state.just_jumped.then_some(state.jump_velocity), + wipeout_impulse_16: (state.slide_wipeout + && matches!(family, Family::Boardslide | Family::Darkslide)) + .then_some(state.slide_impulse), + } +} + +/// Adapt the retained native publication directly. Unwritten family fields, +/// camera state, graph names and selector flags are not reset by FillOut. +pub(crate) fn fill_fields( + family: Family, + state: State, + manager: &ManagerObservation, + out: &mut GrindOutputFields, +) -> SideEffects { + let v = |v: [u32; 4]| v.map(f32::from_bits); + let mut physical = PhysicalOutput { + direction: v(out.direction_0), + point: v(out.point_16), + normal: v(out.normal_32), + across: v(out.across_48), + primitive_endpoints: [v(out.primitive_start_64), v(out.primitive_end_80)], + high_side: v(out.high_side_112), + impact_speed: out.impact_speed_128, + crouch: out.crouch_132, + family, + substate: out.words_136_140[1], + audio_surface: out.audio_surface_216, + spline_guids: out.spline_guids_224_232, + classification: out.trick_out_240, + leaving: out.leaving_317 != 0, + on_front: out.flag_318 != 0, + is_ledge: out.is_ledge_320 != 0, + curb: out.curb_321 != 0, + tipslide_324_325_326: [ + out.dropping_in_324 != 0, + out.tipslide_325 != 0, + out.tipslide_326 != 0, + ], + }; + let side_effects = fill(family, state, manager, &mut physical); + let raw = |v: V| v.map(f32::to_bits); + out.direction_0 = raw(physical.direction); + out.point_16 = raw(physical.point); + out.normal_32 = raw(physical.normal); + out.across_48 = raw(physical.across); + out.primitive_start_64 = raw(physical.primitive_endpoints[0]); + out.primitive_end_80 = raw(physical.primitive_endpoints[1]); + // The optional stores retain the complete existing bytes, not boolized data. + if manager.geometry.kind_1464 == 2 { + out.high_side_112 = raw(physical.high_side); + } + if let Some(guids) = manager.geometry.spline_guids_1296 { + out.spline_guids_224_232 = guids; + } + out.impact_speed_128 = physical.impact_speed; + out.crouch_132 = physical.crouch; + out.words_136_140 = [family as u32, physical.substate]; + out.audio_surface_216 = physical.audio_surface; + out.trick_out_240 = physical.classification; + out.leaving_317 = u8::from(physical.leaving); + out.is_ledge_320 = u8::from(physical.is_ledge); + out.curb_321 = u8::from(physical.curb); + if matches!(family, Family::Tipslide | Family::FiveO | Family::Backslash) { + out.flag_318 = u8::from(physical.on_front); + } + if family == Family::Tipslide { + [out.dropping_in_324, out.tipslide_325, out.tipslide_326] = + physical.tipslide_324_325_326.map(u8::from); + } + side_effects +} diff --git a/crates/skate-game/src/physics/grind/phase.rs b/crates/skate-game/src/physics/grind/phase.rs new file mode 100644 index 00000000..eccf657e --- /dev/null +++ b/crates/skate-game/src/physics/grind/phase.rs @@ -0,0 +1,117 @@ +//! CURRENT GamePhysics/SkaterRuntime adapters. Shared scheduling belongs to main. +use super::super::{GamePhysics, SkaterRuntime}; +use super::{Family, lifecycle}; + +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + if skater.player_state.current() as u32 == 701 { + // Original82D42D80: no feet/drag changes; reset the shared spin only here. + skater.grind.nonspecific_active = true; + skater.grind.nonspecific_jumped = false; + skater.grind.nonspecific_jump_velocity = [0.; 4]; + skater.ground_lifecycle.skeleton_elapsed_16505 = true; + skater.air_reckoning.state.spin_angle = 0.; + skater.air_reckoning.state.spin_speed = 0.; + physics + .board + .hook_mut() + .drive + .disable_animation(&mut skater.ground_lifecycle.board_animated_290); + return Ok(()); + } + let family = Family::from_physical_state(skater.player_state.current() as u32) + .ok_or("Grind Enter requires selected state400..405")?; + if skater.grind.active.is_some() { + return Err("Grind Enter requires prior Exit".into()); + } + let frame = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind Enter requires current board toolkit")? + .deck; + lifecycle::enter( + &mut physics.board, + &mut skater.ground_lifecycle.board_animated_290, + &mut skater.ground_lifecycle.skeleton_elapsed_16505, + &mut skater.skeleton_collision, + ); + skater.grind.states[family as usize].enter(family, frame); + skater.grind.active = Some(family); + Ok(()) +} + +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + if skater.grind.nonspecific_active { + // Vtable8232861C+20 is82B61BB8 (empty), not common grind Exit. + skater.grind.nonspecific_active = false; + return Ok(()); + } + let family = skater + .grind + .active + .ok_or("Grind Exit requires active physical state")?; + let collisions = [19, 15, 20, 16].map(|i| skater.skeleton.definition.bones[i].has_collision); + lifecycle::exit( + &mut physics.board, + &mut skater.ground_lifecycle.board_animated_290, + &mut skater.skeleton_collision, + collisions, + skater.grind.settings.standard_angular_drag, + ); + // Common Exit82D3F448 clears the retained leaving byte96. + skater.grind.states[family as usize].output.leaving = false; + skater.grind.active = None; + // Camera/chromosome history is updated by the ordinary output phase, + // not reset by a physical-state transition. + Ok(()) +} + +pub(crate) fn fill(_physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + if skater.grind.nonspecific_active { + // Original82D430A0 only writes Air442/128 on this retained latch. + if skater.grind.nonspecific_jumped { + skater.player_input.physical.air.launch_velocity_128 = + skater.grind.nonspecific_jump_velocity.map(f32::to_bits); + skater.player_input.physical.air.launched_442 = 1; + } + return Ok(()); + } + let manager = skater + .grind + .manager + .ok_or("Grind Fill requires completed manager observation")?; + let side_effects = skater + .grind + .fill_physical(&manager, &mut skater.player_input.physical.grinds) + .ok_or("Grind Fill requires active state")?; + if let Some(velocity) = side_effects.air_jump_velocity_128 { + skater.player_input.physical.air.launch_velocity_128 = velocity.map(f32::to_bits); + skater.player_input.physical.air.launched_442 = 1; + } + skater.grind.pending_wipeout_impulse = side_effects.wipeout_impulse_16; + super::wipeout_output::publish(skater); + Ok(()) +} + +pub(crate) fn post(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let observations = super::super::wipeout::Observations { + processed: &skater.player_input.processed, + board: &physics.riding.ground, + collision: &skater.collision_feedback, + deck: super::super::solve::deck_frame(&physics.board), + input_board: skater.animated_skeleton.board_frames.animation_target, + world_to_animation: skater.animated_skeleton.roots.world_to_animation, + pose_error: skater.collision_pose_error, + maximum_pose_error: skater.collision_maximum_error, + jump_fix_frames: skater.player_state.post.jump_fix_frames, + air: &skater.air_reckoning.state, + system_up_y: physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: skater.trajectory.selector.grind_locked_to_middle(), + grind_normal: skater.trajectory.selector.grind_normal(), + }; + super::post::check( + &mut skater.wipeout.state, + skater.grind.settings.post, + &observations, + ) +} diff --git a/crates/skate-game/src/physics/grind/post.rs b/crates/skate-game/src/physics/grind/post.rs new file mode 100644 index 00000000..32ed3127 --- /dev/null +++ b/crates/skate-game/src/physics/grind/post.rs @@ -0,0 +1,12 @@ +//! Common grind Post using CURRENT collision observations and request owner. +use super::super::wipeout::Observations; +use skate_core::{physics::grind_forces::post, player::wipeout::Requests}; + +pub(crate) fn check( + state: &mut Requests, + settings: post::Settings, + observations: &Observations<'_>, +) -> Result<(), String> { + post::check(state, settings, &observations.frame()?); + Ok(()) +} diff --git a/crates/skate-game/src/physics/grind/rng.rs b/crates/skate-game/src/physics/grind/rng.rs new file mode 100644 index 00000000..c9bcdc63 --- /dev/null +++ b/crates/skate-game/src/physics/grind/rng.rs @@ -0,0 +1,57 @@ +//! Host-owned orientation stream using recovered82970628 arithmetic. +//! Draw ownership/seed bookkeeping is host policy; do not claim original +//! cross-subsystem interleaving. No random draw occurs until AddNoise asks. +pub(super) struct OrientationRandom { + words: [u32; 8], +} + +impl OrientationRandom { + pub(super) fn new() -> Self { + //Use the existing recovered MotionRandom initialization, not an OS + //seed or a fabricated substitute algorithm. Seed words821642B0. + Self { + words: [ + 0, + 0, + 0xf22d_0e56, + 0x8831_26e9, + 0xc624_dd2f, + 0x0702_c49c, + 0x9e35_3f7d, + 0x6fdf_3b64, + ], + } + } + + ///82D405C8/E0/F0: exactly three successive unsigned draws, in call order. + ///The existing noise leaf owns modulo100000 and angle mapping. + pub(super) fn take_three(&mut self) -> [u32; 3] { + [self.next_u32(), self.next_u32(), self.next_u32()] + } + + fn next_u32(&mut self) -> u32 { + let words = &mut self.words; + let last = words[7]; + let previous = words[6]; + let mut sum = previous.wrapping_add(last); + let mut carry = u32::from(sum < last || sum < previous); + words[6] = sum; + for index in (2..=5).rev() { + let previous = words[index]; + sum = previous.wrapping_add(sum).wrapping_add(carry); + carry = u32::from(sum < previous); + words[index] = sum; + } + words[7] = last.wrapping_add(1); + if words[7] == 0 { + for index in (2..=6).rev() { + words[index] = words[index].wrapping_add(1); + if words[index] != 0 { + break; + } + } + } + words[1] = words[1].wrapping_add(1); + words[2] + } +} diff --git a/crates/skate-game/src/physics/grind/runtime.rs b/crates/skate-game/src/physics/grind/runtime.rs new file mode 100644 index 00000000..1773220a --- /dev/null +++ b/crates/skate-game/src/physics/grind/runtime.rs @@ -0,0 +1,99 @@ +//! Host lifetime for the six native physical-state instances and chromosome. +use super::super::grind_chromosome; +use super::{Family, ManagerObservation, output, settings::Settings, state::State}; +use skate_core::{physics::filtered_state::GrindState, player::input_phase::GrindOutputFields}; +use skate_data::collections::Collections; + +pub(crate) struct Runtime { + pub(super) states: [State; 6], + pub(super) active: Option, + pub(super) nonspecific_active: bool, + pub(super) nonspecific_jumped: bool, + pub(super) nonspecific_jump_velocity: [f32; 4], + orientation_random: super::rng::OrientationRandom, + pub(super) settings: Settings, + pub(super) manager: Option, + /// Exact optional Wipeout output16/flag32 write for main's shared output + /// owner. CURRENT does not yet expose that block in PhysicalPlayerInput. + pub pending_wipeout_impulse: Option<[f32; 4]>, + chromosome: grind_chromosome::Chromosome, +} +impl Runtime { + pub fn load(data: &Collections) -> Result { + Ok(Self { + states: [State::new(); 6], + active: None, + nonspecific_active: false, + nonspecific_jumped: false, + nonspecific_jump_velocity: [0.; 4], + orientation_random: super::rng::OrientationRandom::new(), + settings: Settings::load(data)?, + manager: None, + pending_wipeout_impulse: None, + chromosome: grind_chromosome::Chromosome::uninitialized(), + }) + } + pub fn active_family(&self) -> Option { + self.active + } + pub(super) fn take_orientation_noise(&mut self) -> [u32; 3] { + self.orientation_random.take_three() + } + /// Call immediately after the real input manager finishes its Post phase. + pub fn observe(&mut self, manager: ManagerObservation) { + self.manager = Some(manager); + } + + /// Physical FillOut only. Selector owns grinding316; output reset owns all + /// other templates. Active family comes from lifecycle, not manager selection. + pub fn fill_physical( + &self, + manager: &ManagerObservation, + out: &mut GrindOutputFields, + ) -> Option { + self.active.map(|family| { + output::fill_fields(family, self.states[family as usize].output, manager, out) + }) + } + + /// Run once per completed output publication, including nongrind frames. + /// Ground/Air history is not restricted to the six physical grind states. + pub fn condition_outputs( + &mut self, + input: grind_chromosome::Input, + out: &mut GrindOutputFields, + ) -> Result<(), String> { + let chromosome = &mut self.chromosome; + //Native leaves saved-fakie144 unwritten. Host lifecycle explicitly seeds + //only that byte from the first completed packet; later history wins. + chromosome.initialize_host_fakie(input.fakie_155); + if let Some(publication) = chromosome.update(input) { + grind_chromosome::publish(publication, out); + } + Ok(()) + } + + ///82DE5588/82DE5608 initializes196 to0;82DE5BA0/82DE5E10 copies it. + ///82DE5858 measures velocity/acceleration, not grind distance. No distance + ///accumulator is present in the reviewed82DE56B0 update chain. + pub fn last_grind_distance(&self) -> f32 { + 0.0 + } + + pub fn filtered_output(out: &GrindOutputFields) -> Result { + Ok(GrindState { + kind: out.words_136_140[0] as i32, + scorable_id: out.scorable_id_152 as i32, + name: out + .animation_name_156 + .ok_or("Missing native grind name reset/publication")?, + scoring_name: out + .scoring_name_176 + .ok_or("Missing native scoring grind name reset/publication")?, + on_front: out.flag_318 != 0, + crouch: out.crouch_132, + pathed_guid: out.spline_guids_224_232[0], + local_guid: out.spline_guids_224_232[1], + }) + } +} diff --git a/crates/skate-game/src/physics/grind/settings.rs b/crates/skate-game/src/physics/grind/settings.rs new file mode 100644 index 00000000..a72f996b --- /dev/null +++ b/crates/skate-game/src/physics/grind/settings.rs @@ -0,0 +1,64 @@ +//! Stock runtime data. No map/query settings or manager defaults live here. +use skate_core::physics::grind_forces::{post, reckoning}; +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; + +pub(super) struct Settings { + pub standard_angular_drag: f32, + pub pin_vs_slope: PointGraph<4>, + pub look_ahead: f32, + pub exit_assist: PointGraph<4>, + pub post: post::Settings, + pub reckoning: reckoning::Settings, + pub substate: super::substate::Settings, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let words = data + .words::<8>("physics_grinds", "default", "PinVsSlope")? + .map(f32::from_bits); + let exit = data + .words::<12>("physics_grinds", "default", "ExitAssistVsLeanAngle")? + .map(f32::from_bits); + let wipeout = |name| data.float("physics_wipeout", "default", name); + let graph = |name| -> Result, String> { + let w = data + .words::<16>("physics_reckoning", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: w[..8].try_into().unwrap(), + y: w[8..].try_into().unwrap(), + }) + }; + Ok(Self { + standard_angular_drag: data.float("physicsdeck", "default", "DeckAngularDrag")? + * f32::from_bits(0x426f_ffff), + pin_vs_slope: PointGraph { + x: words[..4].try_into().unwrap(), + y: words[4..].try_into().unwrap(), + }, + look_ahead: data.float("physics_grinds", "default", "GrindLookAheadScalar")?, + exit_assist: PointGraph { + x: exit[4..8].try_into().unwrap(), + y: exit[8..].try_into().unwrap(), + }, + post: post::Settings { + // Original82D90898 loads164/168. Stock schema proves Ground + // contact thresholds, not similarly named Air fields240/244. + max_arm_contact_164: wipeout("Wipeout_GroundSkeletonMaxContactArms")?, + max_body_contact_168: wipeout("Wipeout_GroundSkeletonMaxContact")?, + xz_acceleration_204: wipeout("Wipeout_GrindXZDeck")?, + max_displacement_208: wipeout("Wipeout_GrindSkeletonMaxDisp")?, + max_angular_deck_error_212: wipeout("Wipeout_GrindMaxAngularDeckError")?, + }, + reckoning: reckoning::Settings { + ground_normal_smoothing: data + .words::<4>("physics_reckoning", "default", "GroundNormalSmoothing")? + .map(f32::from_bits), + tilt_vs_rotation: graph("TiltVsRotGround")?, + tilt_vs_slope: graph("TiltVsSlopeGround")?, + }, + substate: super::substate::Settings::load(data)?, + }) + } +} diff --git a/crates/skate-game/src/physics/grind/state.rs b/crates/skate-game/src/physics/grind/state.rs new file mode 100644 index 00000000..03406ce7 --- /dev/null +++ b/crates/skate-game/src/physics/grind/state.rs @@ -0,0 +1,63 @@ +//! Separate native state instances: common ctor82D3F210 and Enter82D3F318. +use super::{Family, output}; +pub(super) type V = [f32; 4]; + +#[derive(Clone, Copy)] +pub(super) struct State { + pub output: output::State, + pub frame: [V; 4], + pub already_jumped: bool, + pub updates: i32, + pub leaving_updates: i32, + pub preparing_jump: bool, +} + +impl State { + pub fn new() -> Self { + Self { + output: output::State { + direction: [0., 1., 0., 0.], + normal: [0., 1., 0., 0.], + across: [0., 1., 0., 0.], + crouch: 0., + leaving: false, + substate: 0, + classification_104: 0, + just_jumped: false, + jump_velocity: [0.; 4], + slide_wipeout: false, + slide_impulse: [0.; 4], + tipslide_97_98_99: [false; 3], + }, + frame: [ + [1., 0., 0., 0.], + [0., 1., 0., 0.], + [0., 0., 1., 0.], + [0.; 4], + ], + already_jumped: false, + updates: 0, + leaving_updates: 0, + preparing_jump: false, + } + } + + pub fn enter(&mut self, family: Family, board: [V; 4]) { + // Enter deliberately does not overwrite vectors48/64/80 or word104. + self.output.substate = 1; + self.output.leaving = false; + self.already_jumped = false; + self.output.just_jumped = false; + self.output.tipslide_97_98_99 = [false; 3]; + self.frame = board; + self.updates = 0; + self.output.crouch = 0.; + self.leaving_updates = 0; + self.output.jump_velocity = [0.; 4]; + if matches!(family, Family::Boardslide | Family::Darkslide) { + // Derived Enter82D41100 clears bytes240/241, not impulse224. + self.output.slide_wipeout = false; + self.preparing_jump = false; + } + } +} diff --git a/crates/skate-game/src/physics/grind/substate.rs b/crates/skate-game/src/physics/grind/substate.rs new file mode 100644 index 00000000..7d8a706f --- /dev/null +++ b/crates/skate-game/src/physics/grind/substate.rs @@ -0,0 +1,153 @@ +//! Original82D40AF8: collision -> pop -> engagement -> derived substate. +//! All writes target current board, manager jumper and skeleton owners. +mod nonspecific; +mod settings; +mod skeleton; +use super::super::{GamePhysics, SkaterRuntime}; +use super::{Family, ManagerObservation}; +pub(super) use nonspecific::execute as execute_nonspecific; +pub(super) use settings::Settings; +use skate_core::{ + math::Vector3, + physics::{force_queue::QueuedPointForce, grind_forces::launch::Input as LaunchInput}, + riding::collision_response::{CollisionResponsePhysical, collision_response}, +}; +type V = [f32; 4]; + +pub(super) fn execute( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + manager: &ManagerObservation, +) -> Result<(), String> { + let family = skater + .grind + .active + .ok_or("Grind substate requires active physical family")?; + let index = family as usize; + let state = skater.grind.states[index]; + let mut jumped_now = false; + let prediction_velocity = if let Some(velocity) = collision_force(physics, skater)? { + //82D40BC8 queues force15; optional velocity is prediction-only here. + Some(velocity) + } else if skater.player_input.processed.flags_2468 & 0x0040_0000 != 0 { + let p = &skater.player_input.processed; + let position = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind pop requires current BoardToolkit")? + .deck[3]; + //Native virtual64 (vertical) precedes virtual60 (geometry side). + let vertical_jump = skater.grind.settings.substate.vertical( + family, + p.state_variant_index_2528, + skater.animation_input.extra.jump_strength, + )?; + let geometry_side_jump = settings::side(family, p.grind.geometry_kind_1464); + let input = LaunchInput { + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + position_112: position, + current_point_1120: p.grind.point_1120.map(f32::from_bits), + balance_2800: skater.animation_input.extra.grind_stability_nudge, + geometry_side_jump, + vertical_jump, + }; + let velocity = super::launch::apply( + &mut physics.board, + &mut skater.player_input.grind.jumper, + &mut skater.player_input.processed.flags_2476, + input, + ); + let state = &mut skater.grind.states[index]; + state.already_jumped = true; + state.output.just_jumped = true; + state.output.jump_velocity = velocity; + jumped_now = true; + Some(velocity) + } else if !state.already_jumped { + if skater.player_input.processed.grind.flags_1516 & 0x4000_0000 != 0 { + let velocity = skater + .player_input + .processed + .grind + .entry_velocity_1184 + .map(f32::from_bits); + //82D40C84 SetVelocity updates all seven real board parts. + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + Some(velocity) + } else { + //Do not restore the copied State after these calls: each derived + //implementation owns its full state writes, including transitions. + match state.output.substate { + 1 => super::contact::advance(physics, skater, manager)?, + 2 => super::involuntary::advance(physics, skater, manager)?, + _ => {} + } + None + } + } else { + None + }; + if let Some(velocity) = prediction_velocity { + let position = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind prediction requires current BoardToolkit")? + .deck[3]; + let dt = skater.player_input.processed.timestep_2604; + let prediction = core::array::from_fn(|i| velocity[i].mul_add(dt, position[i])); + //82D40CFC stores16112 and82D40D0C sets16416. No guessed trajectory. + skater.animated_skeleton.roots.predicted_board_position = prediction; + skater.animated_skeleton.roots.supplied_prediction = Some(prediction); + } + if jumped_now { + skeleton::animated(physics, skater)?; + } else { + //82BDF530 then82C04368: CURRENT wrapper also retains physics error. + super::super::input_phase::update_ground(physics, skater)?; + } + Ok(()) +} + +///Shared82D944E8 caller for common grind and Nonspecific. The late-false +///output is intentionally ignored, and angular displacement is not applied. +///Returns prediction velocity ONLY on the branch that queued force15. +pub(super) fn collision_force( + physics: &mut GamePhysics, + skater: &SkaterRuntime, +) -> Result, String> { + let p = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Grind collision requires current BoardToolkit")?; + let normal = physics.riding.reckoning.ground_normal; + let response = collision_response( + &skater.grind.settings.substate.collision, + CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), + forward: toolkit.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + ground_normal: [normal.x, normal.y, normal.z, 0.], + time_step: p.timestep_2604, + mass: toolkit.total_mass, + }, + ); + let Some(response) = response.filter(|r| r.applied) else { + return Ok(None); + }; + let xyz = |v: V| Vector3::new(v[0], v[1], v[2]); + //A full native queue drops this append but still takes the true branch. + physics.board.forces_mut().append(QueuedPointForce { + tag: 15, + force_world: xyz(response.force), + point_body: xyz(response.point), + }); + Ok(Some(response.target_velocity)) +} diff --git a/crates/skate-game/src/physics/grind/substate/nonspecific.rs b/crates/skate-game/src/physics/grind/substate/nonspecific.rs new file mode 100644 index 00000000..c2927dda --- /dev/null +++ b/crates/skate-game/src/physics/grind/substate/nonspecific.rs @@ -0,0 +1,94 @@ +//!82D42DF0 through board helper82D42F50; caller owns final grind reckoning. +use super::{Family, GamePhysics, LaunchInput, SkaterRuntime, collision_force, skeleton}; +use skate_core::{ + player::selector::conditions::{SkeletonAnimationState, is_skateboard_animated}, + riding::anti_flip::{self, AntiFlipInput}, +}; + +pub(crate) fn execute(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.grind.nonspecific_jumped = false; + let p = &skater.player_input.processed; + if p.flags_2468 & 0x0040_0000 != 0 { + let position = skater + .player_input + .toolkit + .as_ref() + .ok_or("Nonspecific pop requires current BoardToolkit")? + .deck[3]; + let input = LaunchInput { + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + position_112: position, + current_point_1120: p.grind.point_1120.map(f32::from_bits), + balance_2800: skater.animation_input.extra.grind_stability_nudge, + geometry_side_jump: f32::from_bits(0x3f33_3333), + vertical_jump: skater.grind.settings.substate.vertical( + Family::FiftyFifty, + p.state_variant_index_2528, + skater.animation_input.extra.jump_strength, + )?, + }; + let velocity = super::super::launch::apply( + &mut physics.board, + &mut skater.player_input.grind.jumper, + &mut skater.player_input.processed.flags_2476, + input, + ); + skater.grind.nonspecific_jump_velocity = velocity; + skater.grind.nonspecific_jumped = true; + skeleton::animated(physics, skater)?; + //82D42E8C reapplies velocity AFTER animated board/skeleton update. + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + } else { + let animated = is_skateboard_animated(SkeletonAnimationState { + mode_16420: skater.skeleton_input.force_mode, + back_chain_lane_12656: skater.skeleton_input.drive_frames[0][2][1], + threshold_800: skater.grind.settings.substate.animated_board_threshold, + }); + let target = if animated && p.flags_2472 & 0x0040_0000 != 0 { + skeleton::animated_target(physics, skater)? + } else { + skeleton::ground_target(physics, skater)? + }; + //82D42F50 starts with steering0 then82C04368, before angular/support. + let p = &skater.player_input.processed; + skater.ground.steering.update( + 0., + skater.ground_settings.board().steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + skater + .skeleton_air + .capture_physics_error(&physics.board, &target); + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Nonspecific board update requires current BoardToolkit")?; + let up = p.vectors_544_560_592_608[0].map(f32::from_bits); + let correction = anti_flip::calculate( + skater.ground_settings.board().anti_flip, + &AntiFlipInput { + flags_2468: p.flags_2468, + balance: skater.animation_input.fields.balance, + axis_96: toolkit.deck[2], + axis_64: toolkit.deck[0], + projection_axis_544: up, + }, + ); + //82C075B8 adds torque acceleration, not angular velocity/orientation. + skater + .ground_runtime + .apply_angular_displacement(&mut physics.board, correction); + super::super::board::apply_world_force( + &mut physics.board, + up.map(|v| v * -40.), + toolkit.deck[3], + ); + //Unlike common grind,701 discards optional velocity and torque outputs. + collision_force(physics, skater)?; + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/grind/substate/settings.rs b/crates/skate-game/src/physics/grind/substate/settings.rs new file mode 100644 index 00000000..7b083615 --- /dev/null +++ b/crates/skate-game/src/physics/grind/substate/settings.rs @@ -0,0 +1,90 @@ +//! Stock mode endpoints and constructor-vtable-resolved jump getters. +use super::super::Family; +use skate_core::{point_graph::PointGraph, riding::collision_response::CollisionResponseSettings}; +use skate_data::collections::Collections; + +pub(crate) struct Settings { + pub(super) collision: CollisionResponseSettings, + pub(super) animated_board_threshold: f32, + //Mode order is the existing Processed.state_variant_index_2528 contract. + vertical: [[[f32; 2]; 3]; 5], +} + +impl Settings { + pub(crate) fn load(data: &Collections) -> Result { + let f = |name| data.float("physics_collision", "default", name); + let graph = data + .words::<20>("physics_collision", "default", "CollisionTorqueVsAngle")? + .map(f32::from_bits); + let mut vertical = [[[0.; 2]; 3]; 5]; + for (index, mode) in ["easy", "normal", "hardcore", "motorized", "test"] + .into_iter() + .enumerate() + { + for (family, fields) in [ + ["Hash_3097A69281990652", "Hash_FA4CDBAE0DFD1FAD"], + ["Hash_B2B1170AFFC8AC69", "Hash_1B3E9F9C836D287D"], + ["Hash_703829BD711E54DE", "Hash_F2473E9125079F0"], + ] + .into_iter() + .enumerate() + { + vertical[index][family] = [ + data.float("physics_mode", mode, fields[0])?, + data.float("physics_mode", mode, fields[1])?, + ]; + } + } + Ok(Self { + animated_board_threshold: data.float( + "physics_animation", + "default", + "MaxDeckZAxisYForAnimatedDeck", + )?, + collision: CollisionResponseSettings { + maximum_velocity_delta: f("MaxVelDelta")?, + force_y_offset: f("ForceYOffset")?, + force_scalar: f("CollisionForceScalar")?, + target_displacement_velocity: f("TargetDisplacementVel")?, + torque_vs_angle: PointGraph { + x: graph[4..12].try_into().unwrap(), + y: graph[12..20].try_into().unwrap(), + }, + }, + vertical, + }) + } + + pub(super) fn vertical(&self, family: Family, mode: u32, strength: f32) -> Result { + let modes = self + .vertical + .get(mode as usize) + .ok_or_else(|| format!("Invalid grind jump physics mode {mode}"))?; + //Virtual64:82D40D60 common (including Darkslide),82D41CE8 board, + //82D42720 tip. No strength clamp exists in these scalar getters. + let group = match family { + Family::Boardslide => 1, + Family::Tipslide => 2, + _ => 0, + }; + let [low, high] = modes[group]; + Ok((1. - strength).mul_add(low, high * strength)) + } +} + +pub(super) fn side(family: Family, geometry_kind: u32) -> f32 { + match family { + //82D41320 reads processed1464, returning literal820997B8 or82165A10. + Family::Boardslide | Family::Darkslide => { + if geometry_kind == 2 { + f32::from_bits(0x3f66_6666) + } else { + 0. + } + } + //82D42AE8 literal820997B8. + Family::Backslash => f32::from_bits(0x3f66_6666), + //82D41FB0 literal8209975C; shared50-50/tip/5-O virtual60. + _ => f32::from_bits(0x3f00_0000), + } +} diff --git a/crates/skate-game/src/physics/grind/substate/skeleton.rs b/crates/skate-game/src/physics/grind/substate/skeleton.rs new file mode 100644 index 00000000..90cf1592 --- /dev/null +++ b/crates/skate-game/src/physics/grind/substate/skeleton.rs @@ -0,0 +1,64 @@ +//!82D40D28 calls82BDDA10 with fast_blend=false only for a pop THIS update. +use super::{GamePhysics, SkaterRuntime}; +use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; +use skate_core::physics::skeleton_animation_record::AnimationPartTransform; + +pub(super) fn animated( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + animated_target(physics, skater).map(|_| ()) +} + +pub(super) fn animated_target( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater.skeleton_input.update_animated( + &mut skater.skeleton_air, + &mut physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + false, + ) +} + +///Nonspecific performs82C04368 in its subsequent board helper, after steering. +pub(super) fn ground_target( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater.skeleton_input.update_ground( + &physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + ) +} diff --git a/crates/skate-game/src/physics/grind/tip_nudge.rs b/crates/skate-game/src/physics/grind/tip_nudge.rs new file mode 100644 index 00000000..c3fabad4 --- /dev/null +++ b/crates/skate-game/src/physics/grind/tip_nudge.rs @@ -0,0 +1,28 @@ +//! S3 tipslide dropping-in nudge82D421B0, separate from common exit nudge. +//! Scalar reconstruction of original operand flow; ordinary PC floating point, +//! not a claim of bit-exact VMX128 execution or an instruction emulator. +use super::{arithmetic::dot3, board::apply_world_force}; +use skate_core::physics::board_runtime::BoardRuntime; +type V = [f32; 4]; + +pub(super) fn apply( + board: &mut BoardRuntime, + position: V, + point: V, + across: V, +) -> Result<(), String> { + let offset = core::array::from_fn(|i| position[i] - point[i]); + let inward = across.map(|v| if dot3(across, offset) > 0. { -v } else { v }); + //82D422A0..C4 multiplies original820996DC=45 by8206D110=deg-to-rad, + //then RELOADS that product before cosine82473930/sine824531C8. + //Hex-Rays' stale scalar prints one degree; that is not the loaded value. + let angle = 45.0_f32 * f32::from_bits(0x3c8e_fa35); + let (sine, cosine) = skate_core::trigonometry::sin_cos(angle); + //Original v43 combines sine/cosine and v44 is zero. Shift/permutation + //extract cosine for the lateral multiplier and sine for the Y-only subtract. + //Do not rotate the full force vector or scale the Y subtract by100. + let mut force = inward.map(|v| (v * 100.) * cosine); + force[1] -= sine; + apply_world_force(board, force, position); + Ok(()) +} diff --git a/crates/skate-game/src/physics/grind/wipeout_output.rs b/crates/skate-game/src/physics/grind/wipeout_output.rs new file mode 100644 index 00000000..d255d922 --- /dev/null +++ b/crates/skate-game/src/physics/grind/wipeout_output.rs @@ -0,0 +1,17 @@ +//! Complete the grind FillOut write to native PhysOut pointer+12's 48-byte +//! block. CURRENT already represents it as PhysicalPlayerInput::physics. +use crate::physics::SkaterRuntime; + +/// Call immediately after grind FillOut sets pending_wipeout_impulse, before +/// the completed physical packet is consumed by the next input phase. +pub(crate) fn publish(skater: &mut SkaterRuntime) { + if let Some(impulse) = skater.grind.pending_wipeout_impulse.take() { + let output = &mut skater.player_input.physical.physics; + output.vector_16 = impulse.map(f32::to_bits); + output.flag_32 = 1; + } + // No inactive clearing: the ordinary physical output reset owns this block. + // ProcessInput82DB4CCC/4CDC publishes bit2472.11 and vector816; existing + // Wipeout Enter82D3B5E8 caches them, Update82D3BFEC applies deck force. + // This is not a direct body impulse/velocity or animation packet mutation. +} diff --git a/crates/skate-game/src/physics/grind_camera.rs b/crates/skate-game/src/physics/grind_camera.rs index d3ba0465..fcd90760 100644 --- a/crates/skate-game/src/physics/grind_camera.rs +++ b/crates/skate-game/src/physics/grind_camera.rs @@ -1,58 +1,83 @@ -//! TU3 grind direction82D87460 and contact-point conditioner82DF0640. -use skate_core::physics::grind_contact::{FiftyFiftyCandidate, Primitive}; -use skate_core::riding::ground_correction_math::dot_product; +//! ZIP contact conditioner82DF0640, corroborated against S2 82E2D000. +//! Tangent selection82D87460 belongs to the manager, not to this conditioner. +use crate::camera::CameraGrindOutput; type V = [f32; 4]; -#[derive(Default)] -pub(super) struct GrindCamera { +#[derive(Clone, Copy, Debug)] +pub(crate) struct Contact { + pub point: V, pub direction: V, - pub target: V, - previous_target: V, - offset: V, + /// Exact processed primitive endpoints, not the board's two truck hits. + pub endpoints: [V; 2], + pub family: u32, +} + +#[derive(Default)] +pub(crate) struct GrindCamera { + current: V, + previous: V, + error: V, midpoint: V, - kind: u32, + family: u32, active: bool, } + impl GrindCamera { - pub fn exit(&mut self) { - //82DF0610/82DF07EC invalidate history without erasing the rail tangent. - self.active = false; + /// Schedule once after physical FillOut, before camera/graph readers. Uses + /// retained physical direction, including tipslide's latched drop-in frame, + /// rather than rereading a reset or newly selected investigation family. + pub fn condition_fields( + &mut self, + out: &mut skate_core::player::input_phase::GrindOutputFields, + ) { + let vector = |v: [u32; 4]| v.map(f32::from_bits); + let contact = (out.grinding_316 != 0).then(|| Contact { + point: vector(out.point_16), + direction: vector(out.direction_0), + endpoints: [vector(out.primitive_start_64), vector(out.primitive_end_80)], + family: out.words_136_140[0], + }); + let mut camera = CameraGrindOutput { + direction_0: vector(out.direction_0), + camera_target_96: vector(out.camera_target_96), + grinding_316: out.grinding_316, + }; + self.update(contact, &mut camera); + out.direction_0 = camera.direction_0.map(f32::to_bits); + out.camera_target_96 = camera.camera_target_96.map(f32::to_bits); } - pub fn update(&mut self, contact: FiftyFiftyCandidate, edge: Primitive, kind: u32, velocity: V) { - //82D37048 supplies the normalized primitive tangent, independently of - //the deck's facing direction (including backwards50-50s/boardslides). - let delta: V = core::array::from_fn(|i| edge.end[i] - edge.start[i]); - let length = dot_product(delta, delta).sqrt(); - let mut direction = if length > 0.0 { delta.map(|v| v / length) } else { self.direction }; - let along = dot_product(direction, velocity); - if along < 0.0 { - direction = direction.map(|v| -v); - } - //82D874F0: native literals820641A8=.1 and822F8E94=-.9. - if along.abs() < 0.1 && dot_product(self.direction, direction) < -0.9 { - direction = direction.map(|v| -v); - } - self.direction = direction; - - let midpoint: V = core::array::from_fn(|i| (edge.start[i] + edge.end[i]) * 0.5); + /// None invalidates history but leaves the caller's retained output alone. + /// It must not replace inactive direction/target fields with zero. + pub fn update(&mut self, contact: Option, output: &mut CameraGrindOutput) { + let Some(contact) = contact else { + self.active = false; + return; + }; + let midpoint: V = + core::array::from_fn(|i| (contact.endpoints[0][i] + contact.endpoints[1][i]) * 0.5); if !self.active { - self.offset = [0.0; 4]; - self.target = contact.centre; - self.previous_target = contact.centre; - } else if kind != self.kind || midpoint[..3] != self.midpoint[..3] { - //Preserve extrapolated motion across a change of contact owner. - self.offset = core::array::from_fn(|i| { - self.target[i] + (self.target[i] - self.previous_target[i]) - contact.centre[i] + self.error = [0.0; 4]; + self.current = contact.point; + self.previous = contact.point; + } else if contact.family != self.family || midpoint[..3] != self.midpoint[..3] { + self.error = core::array::from_fn(|i| { + self.current[i] + (self.current[i] - self.previous[i]) - contact.point[i] }); } - //Constructor82DF24CC loads literal82072818, .95 per physical tick. - //This decays a contact discontinuity, not ordinary camera movement. - self.offset = self.offset.map(|v| v * 0.95); - self.previous_target = self.target; - self.target = core::array::from_fn(|i| contact.centre[i] + self.offset[i]); + // S3 constructor82DF2514:0.95 per physical update, not dt-based smoothing. + self.error = self.error.map(|v| v * 0.95); + self.previous = self.current; + self.current = core::array::from_fn(|i| contact.point[i] + self.error[i]); + self.family = contact.family; self.midpoint = midpoint; - self.kind = kind; self.active = true; + output.direction_0 = contact.direction; + output.camera_target_96 = self.current; + // grinding_316 is produced by the physical selector; never inferred here. } } + +#[cfg(test)] +#[path = "grind_camera/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/grind_camera/tests.rs b/crates/skate-game/src/physics/grind_camera/tests.rs new file mode 100644 index 00000000..6ecf3d10 --- /dev/null +++ b/crates/skate-game/src/physics/grind_camera/tests.rs @@ -0,0 +1,42 @@ +use super::*; +fn output() -> CameraGrindOutput { + CameraGrindOutput { + direction_0: [3.0; 4], + camera_target_96: [7.0; 4], + grinding_316: 1, + } +} +fn contact(x: f32, family: u32) -> Contact { + Contact { + point: [x, 0.0, 0.0, 0.0], + direction: [1.0, 0.0, 0.0, 0.0], + endpoints: [[0.0; 4], [20.0, 0.0, 0.0, 0.0]], + family, + } +} +#[test] +fn ordinary_motion_is_not_smoothed_but_family_discontinuity_is() { + let mut camera = GrindCamera::default(); + let mut out = output(); + camera.update(Some(contact(1.0, 0)), &mut out); + camera.update(Some(contact(2.0, 0)), &mut out); + assert_eq!(out.camera_target_96[0], 2.0); + camera.update(Some(contact(10.0, 1)), &mut out); + assert!((out.camera_target_96[0] - 3.35).abs() < 0.00001); + assert_eq!(out.grinding_316, 1); +} +#[test] +fn primitive_change_uses_xyz_midpoint_and_inactive_does_not_erase_output() { + let mut camera = GrindCamera::default(); + let mut out = output(); + camera.update(Some(contact(2.0, 0)), &mut out); + let mut next = contact(10.0, 0); + next.endpoints[1][0] = 22.0; + camera.update(Some(next), &mut out); + assert!((out.camera_target_96[0] - 2.4).abs() < 0.00001); + let retained = out.camera_target_96; + camera.update(None, &mut out); + assert_eq!(out.camera_target_96, retained); + camera.update(Some(contact(25.0, 0)), &mut out); + assert_eq!(out.camera_target_96[0], 25.0); +} diff --git a/crates/skate-game/src/physics/grind_chromosome.rs b/crates/skate-game/src/physics/grind_chromosome.rs index f0bd0e31..f9007994 100644 --- a/crates/skate-game/src/physics/grind_chromosome.rs +++ b/crates/skate-game/src/physics/grind_chromosome.rs @@ -1,100 +1,232 @@ -//! TU3 chromosome producers82DEE7E0/82DEE918/82DEED98/82DEEEE0/82DEEFB0. -use skate_core::riding::ground_correction_math::dot_product as dot; -type V = [f32; 4]; +//! ZIP chromosome port corrected from S3 82DEE918/82DEF518/82DEE508. +//! S2 82E2E9E0/82E2EBE8/82E2F9D0 confirms history/publication structure; +//! only S3 has four-way orientation for5050/5O and the sixth darkslide family. +use super::grind::Family; #[path = "grind_names.rs"] -mod names; +pub(crate) mod names; +#[path = "grind_chromosome/pose.rs"] +mod pose; +pub(crate) use pose::{ApproachPose, Input}; +use pose::{add, dot, signed, sub}; -#[derive(Clone, Copy, Default)] -pub(super) struct Pose { - pub animated_board: [V; 4], - pub board: [V; 4], - pub foot_directions: [V; 2], - pub fakie: bool, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Components(pub [u32; 6]); +impl Components { + pub fn name(self) -> names::Name { + // Constructed only from booleans, orientation0..3 and the Family enum. + names::lookup(self.0).expect("validated grind chromosome dimensions") + } } -#[derive(Default)] -pub(super) struct Chromosome { - history: std::collections::VecDeque, - approach_pose: Pose, - approach: usize, - previous_kind: Option, - away_frames: u32, + +///82DEE508's actual PhysOut publication, after update and only while grinding. +/// FastString encoding uses CURRENT's existing original-name encoder. +pub(crate) fn publish( + p: Publication, + out: &mut skate_core::player::input_phase::GrindOutputFields, +) { + use skate_core::animation::skeleton_input::name::encode; + out.volatile_chromosome_244 = p.volatile.0; + if let Some(animation) = p.animation { + let name = animation.name(); + out.animation_chromosome_268 = animation.0; + out.animation_id_144 = name.skating_id as u32; + out.animation_name_156 = Some(encode(name.attribute.as_bytes())); + } + if let Some(scoring) = p.scoring { + let name = scoring.name(); + out.scoring_chromosome_292 = scoring.0; + out.scoring_id_148 = name.skating_id as u32; + out.scorable_id_152 = name.scorable_id as u32; + out.scoring_name_176 = Some(encode(name.attribute.as_bytes())); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Publication { + pub volatile: Components, + pub animation: Option, + pub scoring: Option, +} + +pub(crate) struct Chromosome { + history: std::collections::VecDeque, + saved: ApproachPose, + saved_fakie_initialized: bool, + approach: u32, + previous_category: u32, + previous_kind: Option, + away_frames: i32, reversed: bool, - travel: Option, - pending: [usize; 6], - pending_frames: u32, - animation: [usize; 6], - scoring: [usize; 6], + orientation: Option, + pending: Option, + pending_frames: i32, + animation: Option, + scoring: Option, } + impl Chromosome { - pub fn observe(&mut self, pose: Pose, category: u32) { - if category == 100 { - self.history.push_back(pose); - if self.history.len() > 30 { - self.history.pop_front(); - } - self.approach_pose = pose; - } - if category != 400 { - self.away_frames = self.away_frames.saturating_add(1); - self.previous_kind = None; - self.travel = None; + ///82DEE358 leaves byte144 unwritten. The false storage below is opaque + ///until the host observes the first completed animation packet. + pub fn uninitialized() -> Self { + let mut state = Self::new(false); + state.saved_fakie_initialized = false; + state + } + + ///Explicit host initialization of a native unwritten byte, not a claim + ///about retail allocator contents. Seed once from completed Anim10372; + ///do not replace constructor axes/position or any established history. + pub fn initialize_host_fakie(&mut self, observed_fakie: bool) { + if !self.saved_fakie_initialized { + self.saved.fakie = observed_fakie; + self.saved_fakie_initialized = true; } } - pub fn update( - &mut self, - pose: Pose, - kind: u32, - point: V, - direction: V, - normal: V, - ) -> (&'static str, &'static str) { - let new_grind = self.previous_kind.is_none(); - let reference = if self.history.len() >= 30 { + + /// S3 ctor82DEE358 explicitly initializes the saved axes/position but does + /// not write saved fakie byte144. The supplied value is an explicit host + /// initialization observation, not a recovered native constructor default. + /// A real Air439 snapshot or30 Ground samples replaces this reference. + pub fn new(constructor_fakie_144: bool) -> Self { + Self { + history: std::collections::VecDeque::with_capacity(30), + saved: ApproachPose { + right: [1.0, 0.0, 0.0, 0.0], + position: [0.0; 4], + feet: [[1.0, 0.0, 0.0, 0.0]; 2], + fakie: constructor_fakie_144, + }, + saved_fakie_initialized: true, + approach: 0, + previous_category: 0, + previous_kind: None, + away_frames: 31, + reversed: false, + orientation: None, // Native sentinel4, distinct from forward0. + pending: None, + pending_frames: 0, + animation: None, + scoring: None, + } + } + + fn reference(&self) -> ApproachPose { + //82DEED28: fewer than30 samples uses saved, NOT newest ground pose. + if self.history.len() == 30 { self.history[0] } else { - self.approach_pose + self.saved + } + } + + /// Invoke after physical selection/FillOut with current PhysOut inputs. + /// Category400 and grinding316 are distinct source observations. + pub fn update(&mut self, input: Input) -> Option { + let snapshot = ApproachPose { + right: input.basic_right_0, + position: input.basic_position_48, + feet: input.feet_256_272, + fakie: input.fakie_155, }; - if new_grind && self.away_frames > 30 { - let across = cross(direction, normal); - let from = sub(reference.animated_board[3], point); - let across = signed(across, dot(across, from) > 0.0); + //82DEEB90/82DEEAF8, in this order even if both conditions hold. + if input.air_event_439 { + self.saved = snapshot; + self.saved_fakie_initialized = true; + self.history.clear(); + } + if input.category == 100 { + if self.history.len() == 30 { + self.history.pop_front(); + } + self.history.push_back(snapshot); + } + let new_approach = + input.category == 400 && self.previous_category != 400 && self.away_frames > 30; + if new_approach || (input.category == 400 && self.previous_kind != input.family) { + self.saved = self.reference(); + self.saved_fakie_initialized |= self.history.len() == 30; + } + if new_approach { + let across = signed( + input.across, + dot(input.across, sub(self.saved.position, input.point)) > 0.0, + ); let right = signed( - reference.animated_board[0], + self.saved.right, dot( - add(reference.foot_directions[0], reference.foot_directions[1]), - reference.animated_board[0], + add(self.saved.feet[0], self.saved.feet[1]), + self.saved.right, ) > 0.0, ); - self.approach = usize::from(dot(right, across) > 0.0); + self.approach = u32::from(dot(right, across) > 0.0); } - let board_end = if matches!(kind, 2 | 3 | 4) { - usize::from(dot(sub(point, pose.board[3]), pose.board[2]) <= 0.0) - } else { + self.previous_kind = input.family; + self.away_frames = if input.category == 400 { 0 + } else { + self.away_frames.wrapping_add(1) }; - let projected = sub( - pose.board[2], - normal.map(|v| v * dot(pose.board[2], normal)), - ); - let length = dot(projected, projected).sqrt(); - let straight = length <= 0.01 || (dot(projected, direction) / length).abs() > 0.9397; - let tip_axis = signed( - pose.animated_board[2], - dot(sub(pose.animated_board[3], point), pose.animated_board[2]) > 0.0, - ); - let low = dot(normal, tip_axis) <= -0.1; - let feet = add(pose.foot_directions[0], pose.foot_directions[1]); - let right = signed( - pose.animated_board[0], - dot(feet, pose.animated_board[0]) > 0.0, - ); - let forward = dot(right, direction) > 0.0; - let travel = if matches!(kind, 0 | 3) { - if self.travel.is_none() { - self.reversed = dot( - feet, - add(reference.foot_directions[0], reference.foot_directions[1]), - ) < 0.0; + let output = if let Some(family) = input.family.filter(|_| input.grinding_316) { + //82DEF518: orientation, tilt, then twist, using actual Basic output + //lanes rather than substituting the live board's current transform. + let orientation = self.travel(input, family); + let low = pose::low(input); + let straight = pose::straight(input); + let location = matches!(family, Family::Tipslide | Family::FiveO | Family::Backslash) + && dot( + sub(input.point, input.basic_location_position_144), + input.basic_location_axis_96, + ) <= 0.0; + let components = Components([ + self.approach, + u32::from(location), + u32::from(straight), + u32::from(low), + orientation, + family as u32, + ]); + if self.previous_category != 400 { + self.pending = Some(components); + self.pending_frames = 13; + } else if self.pending == Some(components) { + self.pending_frames = self.pending_frames.wrapping_add(1); + } else { + self.pending = Some(components); + self.pending_frames = 0; + } + if self.pending_frames > 0 { + self.animation = self.pending; + } + if self.pending_frames > 12 { + self.scoring = self.pending; + } + Some(Publication { + volatile: components, + animation: self.animation, + scoring: self.scoring, + }) + } else { + //82DEE508 tail, independent of the category. + self.orientation = None; + self.reversed = false; + None + }; + self.previous_category = input.category; + output + } + + fn travel(&mut self, input: Input, family: Family) -> u32 { + let feet = add(input.feet_256_272[0], input.feet_256_272[1]); + let right = signed(input.basic_right_0, dot(feet, input.basic_right_0) > 0.0); + let forward = dot(right, input.direction) > 0.0; + let orientation = if matches!(family, Family::FiftyFifty | Family::FiveO) { + if self.orientation.is_none() { + assert!( + self.history.len() == 30 || self.saved_fakie_initialized, + "saved fakie144 consumed before an actual history write" + ); + let reference = self.reference(); + self.reversed = dot(feet, add(reference.feet[0], reference.feet[1])) < 0.0; if reference.fakie { self.reversed = !self.reversed; } @@ -106,58 +238,13 @@ impl Chromosome { (false, true) => 3, } } else { - usize::from(!forward) + u32::from(!forward) }; - self.travel = Some(travel); - let chromosome = [ - self.approach, - board_end, - usize::from(straight), - usize::from(low), - travel, - kind as usize, - ]; - //82DEF518 publishes animation after one stable repeat and scoring after - //13; entering a grind publishes both immediately. - if new_grind { - self.pending = chromosome; - self.pending_frames = 13; - } else if self.pending == chromosome { - self.pending_frames = self.pending_frames.saturating_add(1); - } else { - self.pending = chromosome; - self.pending_frames = 0; - } - if self.pending_frames > 0 { - self.animation = self.pending; - } - if self.pending_frames > 12 { - self.scoring = self.pending; - } - self.previous_kind = Some(kind); - self.away_frames = 0; - let animation = index(self.animation); - let scoring = index(self.scoring); - (names::NAMES[animation], names::NAMES[scoring]) + self.orientation = Some(orientation); + orientation } } -fn index(v: [usize; 6]) -> usize { - (((((v[0] * 2 + v[1]) * 2 + v[2]) * 2 + v[3]) * 4 + v[4]) * 6) + v[5] -} -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 signed(v: V, positive: bool) -> V { - v.map(|x| if positive { x } else { -x }) -} -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.0, - ] -} + +#[cfg(test)] +#[path = "grind_chromosome/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/grind_chromosome/pose.rs b/crates/skate-game/src/physics/grind_chromosome/pose.rs new file mode 100644 index 00000000..94fafe21 --- /dev/null +++ b/crates/skate-game/src/physics/grind_chromosome/pose.rs @@ -0,0 +1,80 @@ +use super::Family; +pub(super) type V = [f32; 4]; +pub(super) use skate_core::riding::ground_correction_math::dot_product as dot; + +/// Consumed subset of the144-byte record copied by82DEEAF8. Ground80 and +/// Skeleton144/160 are copied by the original but not read by these producers. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ApproachPose { + pub right: V, + pub position: V, + pub feet: [V; 2], + pub fakie: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Input { + pub category: u32, + pub grinding_316: bool, + pub air_event_439: bool, + ///None represents the native reset/unknown family, not FiftyFifty. + pub family: Option, + pub basic_right_0: V, + pub basic_forward_32: V, + pub basic_position_48: V, + pub basic_location_axis_96: V, + pub basic_twist_axis_128: V, + pub basic_location_position_144: V, + pub feet_256_272: [V; 2], + pub fakie_155: bool, + pub point: V, + pub direction: V, + pub normal: V, + pub across: V, +} + +///82DEED98: twist uses Basic128, not Basic32 or a substituted deck-forward. +pub(super) fn straight(input: Input) -> bool { + let projected = sub( + input.basic_twist_axis_128, + input + .normal + .map(|v| v * dot(input.basic_twist_axis_128, input.normal)), + ); + let length = skate_core::physics::board_motion_output::length(skate_core::math::Vector3::new( + projected[0], + projected[1], + projected[2], + )); + if length <= 0.01 { + return true; + } + let mut inverse = length.recip(); + for _ in 0..2 { + let correction = (-inverse).mul_add(length, 1.0); + inverse = inverse.mul_add(correction, inverse); + } + let along = dot(projected.map(|v| v * inverse), input.direction); + along.abs() > 0.9397 +} + +///82DEEEE0: sign the Basic32 axis from Basic48 minus contact. +pub(super) fn low(input: Input) -> bool { + let axis = signed( + input.basic_forward_32, + dot( + sub(input.basic_position_48, input.point), + input.basic_forward_32, + ) > 0.0, + ); + dot(input.normal, axis) <= -0.1 +} +pub(super) fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn signed(v: V, positive: bool) -> V { + v.map(|x| if positive { x } else { -x }) +} diff --git a/crates/skate-game/src/physics/grind_chromosome/tests.rs b/crates/skate-game/src/physics/grind_chromosome/tests.rs new file mode 100644 index 00000000..c5234e35 --- /dev/null +++ b/crates/skate-game/src/physics/grind_chromosome/tests.rs @@ -0,0 +1,74 @@ +use super::*; +fn input() -> Input { + Input { + category: 400, + grinding_316: true, + air_event_439: false, + family: Some(Family::FiftyFifty), + basic_right_0: [1.0, 0.0, 0.0, 0.0], + basic_forward_32: [0.0, 0.0, 1.0, 0.0], + basic_position_48: [0.0; 4], + basic_location_axis_96: [0.0, 0.0, 1.0, 0.0], + basic_twist_axis_128: [1.0, 0.0, 0.0, 0.0], + basic_location_position_144: [0.0; 4], + feet_256_272: [[1.0, 0.0, 0.0, 0.0]; 2], + fakie_155: false, + point: [0.0; 4], + direction: [1.0, 0.0, 0.0, 0.0], + normal: [0.0, 1.0, 0.0, 0.0], + across: [0.0, 0.0, 1.0, 0.0], + } +} +#[test] +fn family_switch_does_not_fake_new_category_or_skip_scoring_delay() { + let mut state = Chromosome::new(false); //Explicit synthetic constructor observation. + let mut i = input(); + let first = state.update(i).unwrap(); + assert_eq!(first.animation, Some(first.volatile)); + assert_eq!(first.scoring, Some(first.volatile)); + i.family = Some(Family::FiveO); + let switched = state.update(i).unwrap(); + assert_eq!(switched.animation, first.animation); + assert_eq!(switched.scoring, first.scoring); + let repeated = state.update(i).unwrap(); + assert_eq!(repeated.animation, Some(repeated.volatile)); + for _ in 0..11 { + assert_eq!(state.update(i).unwrap().scoring, first.scoring); + } + assert_eq!(state.update(i).unwrap().scoring, Some(repeated.volatile)); +} +#[test] +fn air_snapshot_clears_history_and_short_ground_history_does_not_replace_saved() { + let mut state = Chromosome::new(false); + let mut i = input(); + i.category = 100; + i.grinding_316 = false; + for tick in 0..30 { + i.basic_position_48[0] = tick as f32; + state.update(i); + } + assert_eq!(state.reference().position[0], 0.0); + i.basic_position_48[0] = 30.0; + state.update(i); + assert_eq!(state.reference().position[0], 1.0); + i.category = 200; + i.air_event_439 = true; + i.basic_position_48[0] = 99.0; + state.update(i); + assert_eq!(state.reference().position[0], 99.0); + i.category = 100; + i.air_event_439 = false; + i.basic_position_48[0] = 1000.0; + state.update(i); + assert_eq!(state.reference().position[0], 99.0); +} +#[test] +fn twist_uses_128_not_forward32_and_names_keep_both_id_domains() { + assert!(pose::straight(input())); + let name = names::lookup([0, 0, 0, 0, 0, 5]).unwrap(); + assert_eq!(name.attribute, "FS_DARKSLIDE"); + assert_eq!(name.skating_id, 17); + assert_eq!(name.scorable_id, 270); + assert!(names::lookup([0, 0, 0, 0, 4, 0]).is_none()); + assert!(names::lookup([0, 0, 0, 0, 0, u32::MAX]).is_none()); +} diff --git a/crates/skate-game/src/physics/grind_host.rs b/crates/skate-game/src/physics/grind_host.rs new file mode 100644 index 00000000..eb121682 --- /dev/null +++ b/crates/skate-game/src/physics/grind_host.rs @@ -0,0 +1,40 @@ +//! Bind the manager to the current board materials and authored world queries. +use super::{ + grind_materials::GrindMaterials, + player_input::grind::{self, Host, MaterialMode}, + settings::PhysicsSettings, +}; +use skate_core::{ + air::trajectory::grind_surface::{Probe, ProbeHit}, + physics::{board_runtime::BoardRuntime, board_world::BoardWorld, grind_contact::balance}, +}; + +pub(super) struct LiveHost<'a> { + pub board: &'a mut BoardRuntime, + pub settings: &'a mut PhysicsSettings, + pub materials: &'a GrindMaterials, +} + +impl Host for LiveHost<'_> { + fn apply_material_mode(&mut self, mode: MaterialMode) -> Result<(), String> { + self.materials.apply(mode, self.board, self.settings); + Ok(()) + } + fn surface_probe( + &mut self, + world: &BoardWorld, + actor: [u32; 2], + index: usize, + probe: Probe, + ) -> Result, String> { + grind::world::surface_probe(world, actor, index, probe) + } + fn force_exit_line( + &mut self, + world: &BoardWorld, + actor: [u32; 2], + probe: balance::ForceExitProbe, + ) -> Result, String> { + grind::world::force_exit_line(world, actor, probe) + } +} diff --git a/crates/skate-game/src/physics/grind_materials.rs b/crates/skate-game/src/physics/grind_materials.rs new file mode 100644 index 00000000..f943e82a --- /dev/null +++ b/crates/skate-game/src/physics/grind_materials.rs @@ -0,0 +1,48 @@ +//!82D89DC0/82C090C0 material modes, applied to the live board collider slots. +//! Init82C08C28 clears all three grind materials at82C09084..A4; S2 Init +//!82B374E8 confirms their identities. Grind resistance is a separate force. +use super::{player_input::grind::MaterialMode, settings::PhysicsSettings}; +use skate_core::physics::{board_runtime::BoardRuntime, contact::RetailContactMaterial}; + +pub(crate) struct GrindMaterials { + standard: [RetailContactMaterial; 3], +} + +impl GrindMaterials { + pub fn new(settings: &PhysicsSettings) -> Self { + Self { + standard: [ + settings.standard_wheel_material, + settings.truck_material, + settings.deck_material, + ], + } + } + + pub fn apply( + &self, + mode: MaterialMode, + board: &mut BoardRuntime, + settings: &mut PhysicsSettings, + ) { + let materials = match mode { + MaterialMode::Unchanged => return, + MaterialMode::Standard => self.standard, + MaterialMode::Grind => { + [RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 0., + }; 3] + } + }; + board.set_collision_group(4); + [ + settings.wheel_material, + settings.truck_material, + settings.deck_material, + ] = materials; + //These leaves do not change shape enable flags, angular drag, motion + //activation or wiping-out state. They are not full SetStandard82C03AF8. + } +} diff --git a/crates/skate-game/src/physics/grind_names.rs b/crates/skate-game/src/physics/grind_names.rs index e2d87e86..119a9383 100644 --- a/crates/skate-game/src/physics/grind_names.rs +++ b/crates/skate-game/src/physics/grind_names.rs @@ -1,68 +1,49 @@ -//! TU3 canonical grind strings8206FCE4..8207268C. Mixed radix2/2/2/2/4/6. -//! Recovered table matches SHA2568FA7CCBC..D317 of the NUL-separated names. -pub(super) const NAMES: [&str; 384] = [ - "FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "FS_OVERCROOK", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "FS_CROOK", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "BF_FS_OVERCROOK", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "BF_FS_CROOK", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "FS_OVERWILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "FS_WILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "BF_FS_OVERWILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "BF_FS_WILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "FS_NOSEGRIND", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "FS_NOSEGRIND", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "BF_FS_NOSEGRIND", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "BF_FS_NOSEGRIND", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "FS_OVERWILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "FS_WILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_NOSEBLUNT", "BF_FS_OVERWILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_NOSESLIDE", "BF_FS_WILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_TAILSLIDE", "FS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_BLUNT", "FS_SALAD", "FS_BLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_TAILSLIDE", "BF_FS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_BLUNT", "BF_FS_SALAD", "FS_BLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_TAILSLIDE", "FS_SMITH", "BS_BLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_BLUNT", "FS_FEEBLE", "FS_BLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_TAILSLIDE", "BF_FS_SMITH", "BS_BLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_BLUNT", "BF_FS_FEEBLE", "FS_BLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_TAILSLIDE", "FS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_BLUNT", "FS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_TAILSLIDE", "BF_FS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_BLUNT", "BF_FS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "FS_50_50", "FS_LIP", "FS_TAILSLIDE", "FS_SMITH", "BS_BLUNT", "BS_DARKSLIDE", - "FS_50_50", "FS_BOARD", "FS_BLUNT", "FS_FEEBLE", "FS_BLUNT", "FS_DARKSLIDE", - "BF_FS_50_50", "FS_LIP", "FS_TAILSLIDE", "BF_FS_SMITH", "BS_BLUNT", "BS_DARKSLIDE", - "BF_FS_50_50", "FS_BOARD", "FS_BLUNT", "BF_FS_FEEBLE", "FS_BLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BS_CROOK", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BS_OVERCROOK", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BF_BS_CROOK", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BF_BS_OVERCROOK", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BS_WILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BS_OVERWILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BF_BS_WILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BF_BS_OVERWILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BS_NOSEGRIND", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BS_NOSEGRIND", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BF_BS_NOSEGRIND", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BF_BS_NOSEGRIND", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BS_WILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BS_OVERWILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_NOSESLIDE", "BF_BS_WILLY", "FS_NOSEBLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_NOSEBLUNT", "BF_BS_OVERWILLY", "BS_NOSEBLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_BLUNT", "BS_SALAD", "BS_BLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_BLUNT", "BF_BS_SALAD", "BS_BLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BF_BS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_BLUNT", "BS_FEEBLE", "BS_BLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BS_SMITH", "FS_BLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_BLUNT", "BF_BS_FEEBLE", "BS_BLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BF_BS_SMITH", "FS_BLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_BLUNT", "BS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_BLUNT", "BF_BS_5_O", "BS_BLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BF_BS_5_O", "FS_BLUNT", "FS_DARKSLIDE", - "BS_50_50", "BS_BOARD", "BS_BLUNT", "BS_FEEBLE", "BS_BLUNT", "BS_DARKSLIDE", - "BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BS_SMITH", "FS_BLUNT", "FS_DARKSLIDE", - "BF_BS_50_50", "BS_BOARD", "BS_BLUNT", "BF_BS_FEEBLE", "BS_BLUNT", "BS_DARKSLIDE", - "BF_BS_50_50", "BS_LIP", "BS_TAILSLIDE", "BF_BS_SMITH", "FS_BLUNT", "FS_DARKSLIDE", -]; +//! ZIP short names verified entry-for-entry from original S3 initializer82DE6678. +//!82F92AC0 splits384 records;82DEE508 publishes skating IDs and scoring IDs +//! separately. S2 82E2F9D0 uses160 records (two orientations, five families). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Name { + pub skating_id: i32, + pub attribute: &'static str, + pub display: &'static str, + pub scorable_id: i32, +} +#[path = "grind_names/table0.rs"] +mod table0; +#[path = "grind_names/table1.rs"] +mod table1; +#[path = "grind_names/table2.rs"] +mod table2; +#[path = "grind_names/table3.rs"] +mod table3; +#[path = "grind_names/table4.rs"] +mod table4; +#[path = "grind_names/table5.rs"] +mod table5; +#[path = "grind_names/table6.rs"] +mod table6; +#[path = "grind_names/table7.rs"] +mod table7; + +/// Dimensions: approach2, location2, twist2, tilt2, orientation4, family6. +/// Invalid/sentinel chromosomes are not silently mapped to a fifty-fifty. +pub(crate) fn lookup(c: [u32; 6]) -> Option { + if c.into_iter() + .zip([2, 2, 2, 2, 4, 6]) + .any(|(v, bound)| v >= bound) + { + return None; + } + let i = (((((c[0] * 2 + c[1]) * 2 + c[2]) * 2 + c[3]) * 4 + c[4]) * 6 + c[5]) as usize; + let table = match i / 48 { + 0 => &table0::TABLE, + 1 => &table1::TABLE, + 2 => &table2::TABLE, + 3 => &table3::TABLE, + 4 => &table4::TABLE, + 5 => &table5::TABLE, + 6 => &table6::TABLE, + _ => &table7::TABLE, + }; + Some(table[i % 48]) +} diff --git a/crates/skate-game/src/physics/grind_names/table0.rs b/crates/skate-game/src/physics/grind_names/table0.rs new file mode 100644 index 00000000..5a9f6bee --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table0.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 16, + attribute: "FS_OVERCROOK", + display: "FS Overcrooks", + scorable_id: 32, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 15, + attribute: "FS_CROOK", + display: "FS Crooked", + scorable_id: 26, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 44, + attribute: "BF_FS_OVERCROOK", + display: "FS Overcrooks", + scorable_id: 32, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 43, + attribute: "BF_FS_CROOK", + display: "FS Crooked", + scorable_id: 26, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 12, + attribute: "FS_OVERWILLY", + display: "FS Over Willy", + scorable_id: 33, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 11, + attribute: "FS_WILLY", + display: "FS Willy", + scorable_id: 37, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 42, + attribute: "BF_FS_OVERWILLY", + display: "FS Over Willy", + scorable_id: 33, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 41, + attribute: "BF_FS_WILLY", + display: "FS Willy", + scorable_id: 37, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table1.rs b/crates/skate-game/src/physics/grind_names/table1.rs new file mode 100644 index 00000000..f6dcf335 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table1.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 6, + attribute: "FS_NOSEGRIND", + display: "FS Nosegrind", + scorable_id: 30, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 6, + attribute: "FS_NOSEGRIND", + display: "FS Nosegrind", + scorable_id: 30, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 36, + attribute: "BF_FS_NOSEGRIND", + display: "FS Nosegrind", + scorable_id: 30, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 36, + attribute: "BF_FS_NOSEGRIND", + display: "FS Nosegrind", + scorable_id: 30, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 12, + attribute: "FS_OVERWILLY", + display: "FS Over Willy", + scorable_id: 33, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 11, + attribute: "FS_WILLY", + display: "FS Willy", + scorable_id: 37, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 42, + attribute: "BF_FS_OVERWILLY", + display: "FS Over Willy", + scorable_id: 33, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 4, + attribute: "FS_NOSESLIDE", + display: "FS Noseslide", + scorable_id: 31, + }, + Name { + skating_id: 41, + attribute: "BF_FS_WILLY", + display: "FS Willy", + scorable_id: 37, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table2.rs b/crates/skate-game/src/physics/grind_names/table2.rs new file mode 100644 index 00000000..b18122c3 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table2.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 36, + }, + Name { + skating_id: 7, + attribute: "FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 8, + attribute: "FS_SALAD", + display: "FS Salad", + scorable_id: 34, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 36, + }, + Name { + skating_id: 37, + attribute: "BF_FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 38, + attribute: "BF_FS_SALAD", + display: "FS Salad", + scorable_id: 34, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 9, + attribute: "FS_SMITH", + display: "FS Smith", + scorable_id: 35, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 10, + attribute: "FS_FEEBLE", + display: "FS Feeble", + scorable_id: 27, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 39, + attribute: "BF_FS_SMITH", + display: "FS Smith", + scorable_id: 35, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 40, + attribute: "BF_FS_FEEBLE", + display: "FS Feeble", + scorable_id: 27, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table3.rs b/crates/skate-game/src/physics/grind_names/table3.rs new file mode 100644 index 00000000..a9f06516 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table3.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 7, + attribute: "FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 7, + attribute: "FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 37, + attribute: "BF_FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 37, + attribute: "BF_FS_5_O", + display: "FS 5-0", + scorable_id: 22, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 9, + attribute: "FS_SMITH", + display: "FS Smith", + scorable_id: 35, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 3, + attribute: "FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 10, + attribute: "FS_FEEBLE", + display: "FS Feeble", + scorable_id: 27, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 1, + attribute: "FS_LIP", + display: "FS Lipslide", + scorable_id: 28, + }, + Name { + skating_id: 5, + attribute: "FS_TAILSLIDE", + display: "FS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 39, + attribute: "BF_FS_SMITH", + display: "FS Smith", + scorable_id: 35, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 270, + }, + Name { + skating_id: 35, + attribute: "BF_FS_50_50", + display: "FS 50-50", + scorable_id: 23, + }, + Name { + skating_id: 2, + attribute: "FS_BOARD", + display: "FS Boardslide", + scorable_id: 25, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 40, + attribute: "BF_FS_FEEBLE", + display: "FS Feeble", + scorable_id: 27, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 270, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table4.rs b/crates/skate-game/src/physics/grind_names/table4.rs new file mode 100644 index 00000000..8656e319 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table4.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 32, + attribute: "BS_CROOK", + display: "BS Crooked", + scorable_id: 10, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 33, + attribute: "BS_OVERCROOK", + display: "BS Overcrooks", + scorable_id: 16, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 53, + attribute: "BF_BS_CROOK", + display: "BS Crooked", + scorable_id: 10, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 54, + attribute: "BF_BS_OVERCROOK", + display: "BS Overcrooks", + scorable_id: 16, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 28, + attribute: "BS_WILLY", + display: "BS Willy", + scorable_id: 21, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 29, + attribute: "BS_OVERWILLY", + display: "BS Overwilly", + scorable_id: 17, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 51, + attribute: "BF_BS_WILLY", + display: "BS Willy", + scorable_id: 21, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 52, + attribute: "BF_BS_OVERWILLY", + display: "BS Overwilly", + scorable_id: 17, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table5.rs b/crates/skate-game/src/physics/grind_names/table5.rs new file mode 100644 index 00000000..b58cbf14 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table5.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 23, + attribute: "BS_NOSEGRIND", + display: "BS Nosegrind", + scorable_id: 14, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 23, + attribute: "BS_NOSEGRIND", + display: "BS Nosegrind", + scorable_id: 14, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 46, + attribute: "BF_BS_NOSEGRIND", + display: "BS Nosegrind", + scorable_id: 14, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 46, + attribute: "BF_BS_NOSEGRIND", + display: "BS Nosegrind", + scorable_id: 14, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 28, + attribute: "BS_WILLY", + display: "BS Willy", + scorable_id: 21, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 29, + attribute: "BS_OVERWILLY", + display: "BS Over Willy", + scorable_id: 17, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 21, + attribute: "BS_NOSESLIDE", + display: "BS Noseslide", + scorable_id: 15, + }, + Name { + skating_id: 51, + attribute: "BF_BS_WILLY", + display: "BS Willy", + scorable_id: 21, + }, + Name { + skating_id: 13, + attribute: "FS_NOSEBLUNT", + display: "FS_Noseblunt", + scorable_id: 29, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS_Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 52, + attribute: "BF_BS_OVERWILLY", + display: "BS Over Willy", + scorable_id: 17, + }, + Name { + skating_id: 30, + attribute: "BS_NOSEBLUNT", + display: "BS Noseblunt", + scorable_id: 13, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table6.rs b/crates/skate-game/src/physics/grind_names/table6.rs new file mode 100644 index 00000000..8895aa55 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table6.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 25, + attribute: "BS_SALAD", + display: "BS Salad", + scorable_id: 18, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 24, + attribute: "BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 48, + attribute: "BF_BS_SALAD", + display: "BS Salad", + scorable_id: 18, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 47, + attribute: "BF_BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 27, + attribute: "BS_FEEBLE", + display: "BS Feeble", + scorable_id: 11, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Bluntslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 26, + attribute: "BS_SMITH", + display: "BS Smith", + scorable_id: 19, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 50, + attribute: "BF_BS_FEEBLE", + display: "BS Feeble", + scorable_id: 11, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Bluntslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 49, + attribute: "BF_BS_SMITH", + display: "BS Smith", + scorable_id: 19, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, +]; diff --git a/crates/skate-game/src/physics/grind_names/table7.rs b/crates/skate-game/src/physics/grind_names/table7.rs new file mode 100644 index 00000000..060e74f8 --- /dev/null +++ b/crates/skate-game/src/physics/grind_names/table7.rs @@ -0,0 +1,291 @@ +use super::Name; +pub(super) const TABLE: [Name; 48] = [ + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 24, + attribute: "BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 24, + attribute: "BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 47, + attribute: "BF_BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 47, + attribute: "BF_BS_5_O", + display: "BS 5-0", + scorable_id: 6, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 27, + attribute: "BS_FEEBLE", + display: "BS Feeble", + scorable_id: 11, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 20, + attribute: "BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 26, + attribute: "BS_SMITH", + display: "BS Smith", + scorable_id: 19, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 19, + attribute: "BS_BOARD", + display: "BS Boardslide", + scorable_id: 9, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 50, + attribute: "BF_BS_FEEBLE", + display: "BS Feeble", + scorable_id: 11, + }, + Name { + skating_id: 31, + attribute: "BS_BLUNT", + display: "BS Bluntslide", + scorable_id: 8, + }, + Name { + skating_id: 34, + attribute: "BS_DARKSLIDE", + display: "BS Darkslide", + scorable_id: 269, + }, + Name { + skating_id: 45, + attribute: "BF_BS_50_50", + display: "BS 50-50", + scorable_id: 7, + }, + Name { + skating_id: 18, + attribute: "BS_LIP", + display: "BS Lipslide", + scorable_id: 12, + }, + Name { + skating_id: 22, + attribute: "BS_TAILSLIDE", + display: "BS Tailslide", + scorable_id: 20, + }, + Name { + skating_id: 49, + attribute: "BF_BS_SMITH", + display: "BS Smith", + scorable_id: 19, + }, + Name { + skating_id: 14, + attribute: "FS_BLUNT", + display: "FS Bluntslide", + scorable_id: 24, + }, + Name { + skating_id: 17, + attribute: "FS_DARKSLIDE", + display: "FS Darkslide", + scorable_id: 269, + }, +]; diff --git a/crates/skate-game/src/physics/ground.rs b/crates/skate-game/src/physics/ground.rs index 8f815216..81635a00 100644 --- a/crates/skate-game/src/physics/ground.rs +++ b/crates/skate-game/src/physics/ground.rs @@ -15,6 +15,25 @@ use skate_core::{ pub(crate) const HEIGHT: f32 = -0.035; +/// One authored terrain selection drives both presentation and live queries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Terrain { + Flat, + Course, +} + +impl Terrain { + pub(crate) fn surfaces(self) -> [Vec<[Vector3; 4]>; 4] { + match self { Self::Flat => flat_surfaces(), Self::Course => surfaces() } + } + pub(crate) fn world(self, material: RetailContactMaterial) -> BoardWorld { + match self { + Self::Flat => flat_world(material), + Self::Course => world(material), + } + } +} + /// Authored level dimensions in metres; HEIGHT remains the starting surface. pub(crate) const FLOOR_HEIGHT: f32 = HEIGHT - 1.5; @@ -122,20 +141,41 @@ pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 4] { } pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { + build_world(material, surfaces(), query_edges()) +} + +/// Deliberately authored flat-ground comparison surface. Both the solver and +/// all gameplay probes query these real collision triangles. +pub(crate) fn flat_surfaces() -> [Vec<[Vector3; 4]>; 4] { + let v = Vector3::new; + [ + vec![[ + v(-50.0, HEIGHT, -50.0), + v(50.0, HEIGHT, -50.0), + v(50.0, HEIGHT, 50.0), + v(-50.0, HEIGHT, 50.0), + ]], + vec![], + vec![], + vec![], + ] +} +pub(crate) fn flat_world(material: RetailContactMaterial) -> BoardWorld { + build_world(material, flat_surfaces(), Vec::new()) +} + +fn build_world( + material: RetailContactMaterial, + surfaces: [Vec<[Vector3; 4]>; 4], + edges: Vec, +) -> BoardWorld { let mut triangles = Vec::new(); let rail_faces = crate::grind_world::surfaces(); - for (tag, quads) in surfaces().into_iter().enumerate() { + for (tag, quads) in surfaces.into_iter().enumerate() { for vertices in quads { let rail_face = rail_faces.contains(&vertices); for (half, indices) in [[0, 2, 1], [0, 3, 2]].into_iter().enumerate() { - // Closed rectangular rails have convex perimeter edges. Keep - // the coplanar triangulation diagonal non-convex. Without - // these flags,82AD3130 rejects narrow-rail edge contacts. - let convex = if rail_face { - if half == 0 { 0x60 } else { 0xc0 } - } else { - 0 - }; + let convex = if rail_face { if half == 0 { 0x60 } else { 0xc0 } } else { 0 }; triangles.push(WorldTriangle { triangle: triangle_from_volume( indices.map(|i| vertices[i]), @@ -163,9 +203,12 @@ pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { ) .expect("Authored level contains collision triangles"), matching_group: -1, + // This authored static collision mesh accepts trajectory queries. + rejection_flags: 0, + geometry: 1, pool: QueryPool::Ground, }], - static_edges: query_edges(), + static_edges: edges, //No island or conditional meshes exist in the current static level. island_flags: 0, }; @@ -219,3 +262,7 @@ pub(super) fn query_settings() -> (WorldContactSettings, ContactRetentionSetting }, ) } + +#[cfg(test)] +#[path = "tests/flat_ground.rs"] +mod flat_tests; diff --git a/crates/skate-game/src/physics/ground_animation.rs b/crates/skate-game/src/physics/ground_animation.rs index be4b8ae0..b2c42e4e 100644 --- a/crates/skate-game/src/physics/ground_animation.rs +++ b/crates/skate-game/src/physics/ground_animation.rs @@ -50,8 +50,7 @@ pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> skater.ground_animation.launched = false; let p = &skater.player_input.processed; let com = p.animation_com_to_deck_752.map(f32::from_bits); - let heading = physics.riding.reckoning_frames.heading; - physics.riding.update_ground_reckoning_with_heading( + physics.riding.update_ground_reckoning( &physics.board, super::riding_outputs::RidingPoseInputs { com_to_deck: Vector3::new(com[0], com[1], com[2]), @@ -60,10 +59,10 @@ pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> p.flags_2468, skater.animation_input.fields.balance, p.flags_2476 & 0x4000_0000 != 0, - heading, + p, ); skeleton::advance(physics, skater)?; - skater.ground_lifecycle.skeleton_ground_16388 = true; + skater.skeleton_output.correction.pending = true; board::advance(physics, skater) } pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { diff --git a/crates/skate-game/src/physics/ground_animation/board.rs b/crates/skate-game/src/physics/ground_animation/board.rs index 5a95db48..0531c1f8 100644 --- a/crates/skate-game/src/physics/ground_animation/board.rs +++ b/crates/skate-game/src/physics/ground_animation/board.rs @@ -46,6 +46,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> skater.ground_animation.jump = ground_jump::calculate( GroundJumpInput { flags_2468: p.flags_2468, + flags_2480: p.flags_2480, flags_2484: p.flags_2484, flags_2488: p.flags_2488, effective_forward: t.effective[2], @@ -77,7 +78,18 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> //The native caller does not replace Fill's COM velocity144. let input = super::super::air_phase::selector_input(physics, skater)?; skater.trajectory.launch(info, input, &physics.world)?; - skater.trajectory.update(input, &physics.world)?; + let grind_context = super::super::air_trajectory::GrindContext::from_processed( + &skater.player_input.processed, + skater + .player_input + .toolkit + .as_ref() + .ok_or("Jump trajectory requires current board toolkit")? + .deck[3], + ); + skater + .trajectory + .update(input, &physics.world, grind_context)?; //Launch82D678DC stores packet at1920;128 is selector2048. Update's //second pass may replace that retained launch velocity before this read. skater.ground_animation.launch_velocity = skater diff --git a/crates/skate-game/src/physics/ground_animation/settings.rs b/crates/skate-game/src/physics/ground_animation/settings.rs index 891949bc..6d17d7d1 100644 --- a/crates/skate-game/src/physics/ground_animation/settings.rs +++ b/crates/skate-game/src/physics/ground_animation/settings.rs @@ -44,6 +44,8 @@ impl GroundAnimationSettings { adjust_z_factor: f("JumpAdjustZFactor")?, adjust_x_factor: f("JumpAdjustXFactor")?, absolute_minimum_height: f("AbsoluteMinHeight")?, + hippy_minimum_height: f("HippyJumpMinHeight")?, + hippy_maximum_height: f("HippyJumpMaxHeight")?, }, }) } diff --git a/crates/skate-game/src/physics/ground_exit.rs b/crates/skate-game/src/physics/ground_exit.rs index e18fd31c..d1eda5f3 100644 --- a/crates/skate-game/src/physics/ground_exit.rs +++ b/crates/skate-game/src/physics/ground_exit.rs @@ -6,13 +6,18 @@ pub(super) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { state.steering_damped_turn_2644 = 0.0; state.steering_push_scalar_2640 = 1.0; skater.ground.pumping.reset(); - for body in physics.board.bodies_mut() { body.inertia.linear_drag = 0.0; } + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = 0.0; + } physics.settings.wheel_material = physics.settings.standard_wheel_material; if state.flag_2708 { let p = &skater.player_input.processed; - let gravity = [0.0,p.gravity_2648,0.0,0.0]; - let reference = std::array::from_fn(|i| gravity[i].mul_add(p.timestep_2604,state.vector_2688[i])); - let velocity = clamp_jump_velocity(reference,p.vectors_400_416[0].map(f32::from_bits)); - skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + let gravity = [0.0, p.gravity_2648, 0.0, 0.0]; + let reference = + std::array::from_fn(|i| gravity[i].mul_add(p.timestep_2604, state.vector_2688[i])); + let velocity = clamp_jump_velocity(reference, p.vectors_400_416[0].map(f32::from_bits)); + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); } } diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs index 5ab412ea..b6b9ce13 100644 --- a/crates/skate-game/src/physics/ground_phase.rs +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -5,7 +5,7 @@ use super::{ GamePhysics, ground_runtime::{ GroundEntryTargets, GroundInputObservations, GroundLaunchInfo, GroundLaunchPhysical, - GroundPhysicalFrame, GroundTrajectoryState, GroundUpdateFrame, GroundUpdateTargets, + GroundPhysicalFrame, GroundUpdateFrame, GroundUpdateTargets, }, skater::SkaterRuntime, skeleton_controller::SkeletonControllerState, @@ -31,16 +31,14 @@ pub(crate) struct GroundEdge { pub(crate) struct GroundLifecycle { pub skeleton_controller: SkeletonControllerState, - /// These are the actual retained Skeleton lifecycle flags, also shared - /// with teleport and GeneralUpdate by the coordinator. + /// Retained Skeleton lifecycle flag, also shared with teleport. + /// Skeleton16388 instead belongs solely to SkeletonOutput::correction. pub skeleton_elapsed_16505: bool, - pub skeleton_ground_16388: bool, pub board_animated_290: u8, /// Processed2724: Reset82BFA35C clears this. The original image contains /// no other direct scalar writer at that offset. pub manual_drag_2724: f32, pub edge: Option, - pub trajectory: GroundTrajectoryState, /// A selected wall jump is retained for its actual selector continuation. /// It cannot be discarded as if Ground's ordinary tail had completed. pub pending_wall_jump: Option, @@ -50,17 +48,9 @@ impl GroundLifecycle { Self { skeleton_controller: SkeletonControllerState::new(), skeleton_elapsed_16505: false, - skeleton_ground_16388: false, board_animated_290: 0, manual_drag_2724: 0.0, edge: None, - trajectory: GroundTrajectoryState { - pending_request: None, - primary_valid_288: false, - secondary_valid_592: false, - result_valid_9840: false, - flags_12836: 0, - }, pending_wall_jump: None, } } @@ -90,6 +80,19 @@ pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re .toolkit .as_ref() .ok_or("Ground entry requires PlayerInput's current board toolkit")?; + //82D37560: full SetStandard, after SetPhysicsState's possession Stop and + //old-state Exit, before Ground disables the animation drives. Use retained + //stock materials and publish the actual collider flags, not reset defaults. + use skate_core::player::offboard::board_possession::lifecycle::Effects as _; + skater + .board_possession_live + .effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ) + .standard_board(); + skater.board_possession_live.publish_volumes(physics); enter_components( &mut physics.board, &skater.player_input.processed, @@ -197,11 +200,12 @@ pub(crate) fn advance( predicted[2] += delta.z; Ok(()) }; + let grind_context = super::air_trajectory::GrindContext::from_processed(&skater.player_input.processed, super::solve::deck_frame(&physics.board)[3]); let mut launch = |info: &GroundLaunchInfo| { let mut input = selector_input; input.board_vertical_velocity = info.velocity[1]; skater.trajectory.launch(info.selector_launch(), input, &physics.world)?; - skater.trajectory.update(input, &physics.world)?; + skater.trajectory.update(input, &physics.world, grind_context)?; Ok(()) }; let physical = GroundPhysicalFrame { @@ -270,10 +274,10 @@ pub(crate) fn advance( GroundUpdateTargets { foot_ik: &mut skater.foot_ik, skeleton_elapsed_16505: &mut life.skeleton_elapsed_16505, - skeleton_ground_16388: &mut life.skeleton_ground_16388, + board_correction_pending: &mut skater.skeleton_output.correction.pending, // Original830BD4A0 initializer82F825F0 splats8216DEE0=-1. move_future_deck: &mut move_future, - trajectory: &mut life.trajectory, + offboard_grab: &mut skater.offboard_grab, }, ); // These source writes occur during update. Publish even on a later branch @@ -291,3 +295,7 @@ fn lanes(v: Vector3) -> [f32; 4] { fn xyz(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } + +#[cfg(test)] +#[path = "onboard_correction_tests.rs"] +mod correction_tests; diff --git a/crates/skate-game/src/physics/ground_runtime/corrections.rs b/crates/skate-game/src/physics/ground_runtime/corrections.rs index eaf46ed8..51739c3b 100644 --- a/crates/skate-game/src/physics/ground_runtime/corrections.rs +++ b/crates/skate-game/src/physics/ground_runtime/corrections.rs @@ -79,14 +79,6 @@ impl GroundRuntime { position, ); } - pub fn apply_wheel_catch(&mut self, board: &mut BoardRuntime) { - let frame = board.part_transforms()[BodyId::Deck.index()].basis.columns; - let value = ground_correction_math::wheel_catch_displacement( - [frame[1][0], frame[1][1], frame[1][2], 0.], - [frame[2][0], frame[2][1], frame[2][2], 0.], - ); - self.apply_angular_displacement(board, value); - } pub fn pin_to_position( &mut self, board: &mut BoardRuntime, diff --git a/crates/skate-game/src/physics/ground_runtime/entry.rs b/crates/skate-game/src/physics/ground_runtime/entry.rs index df76462d..6f1e8728 100644 --- a/crates/skate-game/src/physics/ground_runtime/entry.rs +++ b/crates/skate-game/src/physics/ground_runtime/entry.rs @@ -61,7 +61,11 @@ impl GroundState { toolkit: &BoardToolkit, targets: GroundEntryTargets<'_>, ) -> Result<(), String> { - restore_standard_board_fields(board, targets.board_flags_8384, self.entry_settings.deck_angular_drag); + restore_standard_board_fields( + board, + targets.board_flags_8384, + self.entry_settings.deck_angular_drag, + ); targets.foot_ik.enable_feet(true); *targets.skeleton_elapsed_16505 = false; (targets.set_skeleton_collision_state)(6)?; @@ -128,6 +132,19 @@ impl GroundState { Ok(()) } } +/// Board-owned portion of SetStandard82C03AF8. 82C090D4/90EC write +/// assembly/part COLLISION groups, not rigid-body activation flags. Material +/// bindings and volume enables still require the live settings/volume owner. +fn restore_standard_board_fields(board: &mut BoardRuntime, flags: &mut u8, drag: f32) { + *flags &= 0x7f; + board.bodies_mut()[6].inertia.angular_drag = drag; + board.set_collision_group(4); +} + +#[cfg(test)] +#[path = "entry_restoration_tests.rs"] +mod restoration_tests; + struct ManualBodies<'a>(&'a mut BoardRuntime); impl ManualGroundBodies for ManualBodies<'_> { fn linear_velocity(&mut self, part: usize) -> [f32; 4] { @@ -145,13 +162,3 @@ impl ManualGroundProjection for Projection { Ok(dot_product(n, v)) } } - -// SetStandard restores collision groups; activation flags belong to body state. -fn restore_standard_board_fields(board: &mut BoardRuntime, flags: &mut u8, drag: f32) { - *flags &= 0x7f; - board.bodies_mut()[6].inertia.angular_drag = drag; - board.set_collision_group(4); -} -#[cfg(test)] -#[path = "entry_restoration_tests.rs"] -mod restoration_tests; diff --git a/crates/skate-game/src/physics/ground_runtime/launch.rs b/crates/skate-game/src/physics/ground_runtime/launch.rs index 6b93c51f..6024e009 100644 --- a/crates/skate-game/src/physics/ground_runtime/launch.rs +++ b/crates/skate-game/src/physics/ground_runtime/launch.rs @@ -15,11 +15,14 @@ pub(crate) struct GroundLaunchInfo { pub skeleton_vector_16240: [f32; 4], pub board_position: [f32; 4], pub physical_center_of_mass: [f32; 4], + #[allow(dead_code)] pub vector_224: [f32; 4], + #[allow(dead_code)] pub vector_240: [f32; 4], pub cone_angle_x: f32, pub cone_angle_z: f32, pub time_step: f32, + #[allow(dead_code)] pub flag_269: bool, pub wall_jump: bool, pub flags_270: u16, diff --git a/crates/skate-game/src/physics/ground_runtime/mod.rs b/crates/skate-game/src/physics/ground_runtime/mod.rs index ffcee36e..0d5887b6 100644 --- a/crates/skate-game/src/physics/ground_runtime/mod.rs +++ b/crates/skate-game/src/physics/ground_runtime/mod.rs @@ -4,13 +4,9 @@ mod corrections; mod entry; mod state; mod update; -pub(crate) use update::{GroundUpdateFrame, GroundUpdateTargets}; -mod trajectory; pub(crate) use entry::GroundEntryTargets; -pub(crate) use trajectory::GroundTrajectoryState; -mod surface; pub(crate) use state::GroundState; -pub(crate) use surface::{active_surface, surface_key}; +pub(crate) use update::{GroundUpdateFrame, GroundUpdateTargets}; mod input; mod pumping; pub(crate) use input::GroundInputObservations; @@ -20,8 +16,7 @@ mod services; mod settings; pub(crate) use launch::{GroundLaunchInfo, GroundLaunchPhysical}; pub(crate) use services::{GroundControllers, GroundPhysicalFrame}; -pub(crate) use settings::GroundSettings; -pub(crate) use settings::GroundProfiles; +pub(crate) use settings::{GroundSettings, GroundProfiles}; use skate_core::{ math::Vector3, physics::{ @@ -97,19 +92,13 @@ impl GroundRuntime { collision_force: None, }) } - pub fn contact_response( - &mut self, - frame: GroundContactFrame, - physical: WallRidePhysical, - ) -> GroundContactResponse { - self.contact = - wall_ride_response(&self.wall_ride, physical, frame, self.contact.vector_2688); - self.contact - } /// Slide82D3AA74..AA9C passes a fresh zero previous vector each update. /// This call does not overwrite Ground's separately retained contact state. pub fn contact_response_with_previous( - &self, frame: GroundContactFrame, physical: WallRidePhysical, previous: [f32;4], + &self, + frame: GroundContactFrame, + physical: WallRidePhysical, + previous: [f32; 4], ) -> GroundContactResponse { wall_ride_response(&self.wall_ride, physical, frame, previous) } @@ -168,3 +157,6 @@ fn curve(data: &Collections, class: &str, field: &str) -> Result, fn xyz(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } + +mod surface; +pub(crate) use surface::{active_surface, surface_key}; diff --git a/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs index a3965f07..25fd40b2 100644 --- a/crates/skate-game/src/physics/ground_runtime/settings.rs +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -20,6 +20,7 @@ use skate_core::{ }, }; use skate_data::collections::Collections; + use std::sync::Arc; /// Immutable stock tables selected by the processed packet, including its diff --git a/crates/skate-game/src/physics/ground_runtime/update.rs b/crates/skate-game/src/physics/ground_runtime/update.rs index 376a6c8d..b8cf7efc 100644 --- a/crates/skate-game/src/physics/ground_runtime/update.rs +++ b/crates/skate-game/src/physics/ground_runtime/update.rs @@ -7,7 +7,7 @@ use super::{ riding_outputs::RidingOutputs, }, GroundControllers, GroundInputObservations, GroundPhysicalFrame, GroundRuntime, GroundSettings, - GroundState, GroundTrajectoryState, + GroundState, }; use skate_core::{ math::Vector3, @@ -28,16 +28,17 @@ pub(crate) struct GroundUpdateFrame<'a> { pub base_trucks: [RetailAffineTransform; 2], pub extra: GroundInputObservations, } -pub(crate) struct GroundUpdateTargets<'a, T> { +pub(crate) struct GroundUpdateTargets<'a> { pub foot_ik: &'a mut FootIk, pub skeleton_elapsed_16505: &'a mut bool, - pub skeleton_ground_16388: &'a mut bool, + /// Borrow the canonical SkeletonOutput correction latch (Skeleton16388). + pub board_correction_pending: &'a mut bool, ///Applies to SkeletonDrives hook432 body and Skeleton16112 together. pub move_future_deck: &'a mut dyn FnMut(Vector3) -> Result<(), String>, - pub trajectory: &'a mut GroundTrajectoryState, + pub offboard_grab: &'a mut super::super::biped_ground::grab_runtime::Owner, } impl GroundState { - pub fn update( + pub fn update( &mut self, runtime: &mut GroundRuntime, board: &mut BoardRuntime, @@ -45,7 +46,7 @@ impl GroundState { settings: &GroundSettings, frame: GroundUpdateFrame<'_>, physical: GroundPhysicalFrame<'_>, - targets: GroundUpdateTargets<'_, T>, + targets: GroundUpdateTargets<'_>, ) -> Result { if !self.entered { return Err("Ground::Enter must finish before Ground::Update".into()); @@ -97,18 +98,6 @@ impl GroundState { input, physical, )?; - if p.frames_since_teleport_2584 % 6 == 0 { - bevy::log::debug!(target: "skate_game::riding_trace", - frame = p.frames_since_teleport_2584, - speed = p.scalar_2612, wheels = p.wheel_count_2556, - fields = ?frame.animation.fields, - steering = ?self.steering, speed_model = ?self.speed, - outcome = ?outcome, "RIDING_GROUND"); - bevy::log::debug!(target: "skate_game::riding_trace", - frame = p.frames_since_teleport_2584, - parts = ?board.bodies(), contacts = ?board.contact_reports(), - forces = ?board.forces().entries(), "RIDING_BOARD"); - } let normal = p.vectors_464_480_496_512_528[0].map(f32::from_bits); if let Some(delta) = motion::future_deck_displacement( board.forces(), @@ -123,9 +112,10 @@ impl GroundState { if self.state.flag_2722 { targets.foot_ik.state.contacts.support_failed_this_update = true; } - *targets.skeleton_ground_16388 = true; + *targets.board_correction_pending = true; self.state.finish_update(p.timestep_2604); - targets.trajectory.cancel(); + //82D37F10 calls82D749D0 on the same PlayerGrabSpline owner as offboard. + targets.offboard_grab.invalidate(); Ok(outcome) } } diff --git a/crates/skate-game/src/physics/handplant.rs b/crates/skate-game/src/physics/handplant.rs index 17d37045..19a781c2 100644 --- a/crates/skate-game/src/physics/handplant.rs +++ b/crates/skate-game/src/physics/handplant.rs @@ -141,16 +141,21 @@ pub(super) fn ground_update( return Ok(()); } if let Some((candidate, com, velocity, normal, heading)) = skater.handplant.pending.take() { - let surface = skate_core::air::trajectory::grind_surface::investigate( - &physics.world, - candidate.edge.start, - candidate.edge.end, - candidate.point, - skater.handplant.settings.truck_distance, - )?; + use skate_core::air::trajectory::grind_surface::{self, InvestigationInput, GeometryType}; + let query = InvestigationInput { + start: candidate.edge.start, + end: candidate.edge.end, + reference: candidate.point, + optional_probe: None, + deck_center_to_truck: skater.handplant.settings.truck_distance, + }; + let surface = grind_surface::investigate(query, |index, probe| { + super::player_input::grind::world::surface_probe(&physics.world, + [p.actor_query_2948, p.actor_query_2952], index, probe) + })?; bevy::log::info!("HANDPLANT_SURFACE tick={} kind={:?} point={:?}", - physics.ticks, surface.as_ref().map(|s| s.evidence.kind), candidate.point); - if surface.is_some_and(|s| s.evidence.kind != 3) { + physics.ticks, surface.kind, candidate.point); + if grind_surface::prepare(query).is_some() && surface.kind != GeometryType::Impossible { skater.handplant.launch( candidate, com, @@ -229,12 +234,12 @@ pub(super) fn ground_query(physics: &GamePhysics, skater: &mut SkaterRuntime) { velocity, normal, h.direction_hint, - &physics.grind.primitives, + physics.grind_world.primitives(), ); if candidate.is_some() || physics.ticks % 30 == 0 { bevy::log::info!("HANDPLANT_QUERY tick={} speed={} vy={} normal={:?} minimum_speed={} minimum_slope={} edges={} candidate={:?} prior_flags={:08x} phase={} processed={:08x}/{:08x}", physics.ticks, length(velocity), velocity[1], normal, h.settings.minimum_speed, - h.settings.minimum_slope, physics.grind.primitives.len(), candidate.map(|c| c.point), + h.settings.minimum_slope, physics.grind_world.primitives().len(), candidate.map(|c| c.point), h.flags, h.phase, p.flags_2476, p.flags_2480); } //82D61268 clears the active output when submission begins. diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs index bee5e8f1..1e2c66da 100644 --- a/crates/skate-game/src/physics/input_phase.rs +++ b/crates/skate-game/src/physics/input_phase.rs @@ -38,6 +38,12 @@ pub(super) fn advance( actions: &mut dyn ActionMap, input_available: bool, ) -> Result { + //82DB4094 completes the preceding off-board batch BEFORE input reset. + skater.offboard_contact.begin_input(); + //82DB409C clears the actual shared manager latch;82DB4348 publishes its IK offset. + skater.landing_deck.manager.can_land_256 = false; + skater.player_input.player.manager_1852_vector_176 = + skater.landing_deck.manager.ik_offset_176.map(f32::to_bits); let host = InputHostFrame { // Native actor queries are host identity, not a physical parameter. actor_query_56: 0, @@ -45,14 +51,28 @@ pub(super) fn advance( input_available, transition_action: actions.value(71), published_board_transform: if skater.player_input.physical.state.flag_61 != 0 { - skater.player_input.physical.teleport_output + skater + .player_input + .physical + .teleport_output .ok_or("Teleport State61 requires its published reset transform")? - .transform.map(|row| row.map(f32::from_bits)) - } else { super::solve::deck_frame(&physics.board) }, + .transform + .map(|row| row.map(f32::from_bits)) + } else { + super::solve::deck_frame(&physics.board) + }, //82D8ABD8 reads the state selector's retained signed counter+40, //before this tick's CalcSuggestedState advances it at82D8AF78..94. air_counter_40: skater.player_state.selector.post_grind_jump_counter, }; + // TU3 82DB8CE4..8D1C stops possession before moving either assembly. + let resetting = skater.player_input.physical.state.flag_61 != 0 + || skater.player_input.player.flags_1296 & (1 << 19) != 0 + || skater.player_input.pending_teleport().is_some(); + if resetting { + skater.offboard_air_selector.reset(); + super::offboard::board_manager::runtime::reset_for_teleport(physics, skater); + } let collision = collision(skater); let mut callbacks = Callbacks { skeleton_input: &mut skater.skeleton_input, @@ -71,10 +91,13 @@ pub(super) fn advance( collision, ground: &mut skater.ground, life: &mut skater.ground_lifecycle, + offboard_grab: &mut skater.offboard_grab, wipeout: &mut skater.wipeout, riding: &mut physics.riding, wiping_out: &mut physics.board_wiping_out, - settings: &physics.settings, + settings: &mut physics.settings, + grind_materials: &physics.grind_materials, + air_targeting_grind: skater.trajectory.selector.grind_locked_to_middle(), globals: &skater.animation.packet.hierarchy, attributes: skater.animation.attributes.entries(), actions, @@ -90,6 +113,8 @@ pub(super) fn advance( host, &mut callbacks, super::player_input::InputStage::ThroughTeleport, + &physics.world, + &physics.grind_world, )?; if let Some(continuation) = continuation { skater.player_input.process_stage( @@ -100,10 +125,17 @@ pub(super) fn advance( host, &mut callbacks, super::player_input::InputStage::AfterTeleport(continuation), + &physics.world, + &physics.grind_world, )?; } + let teleported = callbacks.teleported; + if teleported { + // TU3 82DB8D6C..8DA4 repeats the controller reset after Skeleton. + super::offboard::board_manager::runtime::reset_for_teleport(physics, skater); + } physics.processed_flags_2468 = skater.player_input.processed.flags_2468; - Ok(callbacks.teleported) + Ok(teleported) } pub(super) fn update_ground( @@ -131,7 +163,9 @@ pub(super) fn update_ground( )?; //Ground82D38000 completes GeneralUpdate;82D38008 then retains the actual //board/animation error used when Air subsequently blends its board target. - skater.skeleton_air.capture_physics_error(&physics.board, &target); + skater + .skeleton_air + .capture_physics_error(&physics.board, &target); Ok(()) } @@ -162,10 +196,13 @@ struct Callbacks<'a, 'p> { collision: CollisionInput, ground: &'a mut GroundState, life: &'a mut GroundLifecycle, + offboard_grab: &'a mut super::biped_ground::grab_runtime::Owner, wipeout: &'a mut super::wipeout::Wipeout, riding: &'a mut RidingOutputs, wiping_out: &'a mut bool, - settings: &'a PhysicsSettings, + settings: &'a mut PhysicsSettings, + grind_materials: &'a super::grind_materials::GrindMaterials, + air_targeting_grind: bool, globals: &'a [NativeMatrix], attributes: &'a [AnimationAttribute], actions: &'a mut dyn ActionMap, @@ -173,6 +210,34 @@ struct Callbacks<'a, 'p> { teleported: bool, } impl PlayerInputCallbacks for Callbacks<'_, '_> { + fn prepare_grind( + &mut self, + board: &mut BoardRuntime, + grind: &mut super::player_input::grind::GrindInputState, + processed: &ProcessedPhysicsInput, + world: &skate_core::physics::board_world::BoardWorld, + provider: &crate::grind_world::StaticProvider, + air_counter: i32, + ) -> Result { + let extra = &self.animation_input.extra; + let context = super::player_input::grind::PreContext { + board: super::solve::deck_frame(board), + air_counter, + tip_state: processed.state_2504, + air_targeting_grind_9653: self.air_targeting_grind, + balance_2720: self.animation_input.fields.balance, + translation_2796: extra.grind_translation, + stability_nudge_2800: extra.grind_stability_nudge, + up_down_2804: extra.grind_up_down, + grab_min_height_2808: extra.grind_grab_min_height, + }; + let mut host = super::grind_host::LiveHost { + board, + settings: self.settings, + materials: self.grind_materials, + }; + grind.pre_update(processed, provider, world, context, &mut host) + } fn process_skeleton( &mut self, board: &mut BoardRuntime, @@ -217,15 +282,3 @@ impl PlayerInputCallbacks for Callbacks<'_, '_> { self.reset_player(board, runtime, target, player, physical, processed) } } - -///Grind pop82D40D30 calls the animated skeleton update with fast blend=false. -pub(super) fn update_grind_jump(physics:&mut GamePhysics,skater:&mut SkaterRuntime)->Result<(),String> { - let collision=collision(skater); - let mut owners=SkeletonOwners {animated:&mut skater.animated_skeleton,body:&mut skater.skeleton, - drives:&mut skater.skeleton_drives,ik:&mut skater.foot_ik,animation_input:&mut skater.animation_input, - correction:&mut skater.skeleton_output.correction,pose_errors:&mut skater.pose_errors}; - skater.skeleton_input.update_animated(&mut skater.skeleton_air,&mut physics.board, - &physics.riding.reckoning_frames.system,&mut skater.player_input.processed,&mut owners, - &skater.animation.packet.hierarchy,&collision,physics.settings.step.simulation,false)?; - Ok(()) -} diff --git a/crates/skate-game/src/physics/input_teleport.rs b/crates/skate-game/src/physics/input_teleport.rs index a7ae5f94..4a61e90f 100644 --- a/crates/skate-game/src/physics/input_teleport.rs +++ b/crates/skate-game/src/physics/input_teleport.rs @@ -20,6 +20,16 @@ impl Callbacks<'_, '_> { .skeleton_controller .request_ground(self.collision_mode)?; let target = horizontal_spawn(requested); + bevy::log::info!( + on_board = p.byte_1600 & 1 != 0, + requested_x = requested[3][0], + requested_y = requested[3][1], + requested_z = requested[3][2], + applied_x = target[3][0], + applied_y = target[3][1], + applied_z = target[3][2], + "player teleport spawn" + ); player.flags_1296 = ((player.flags_1296 & 0xffef_ffff) & 0x81ff_ffff) | 0x6000_0000; player.previous_spin_input_1360 = 0.0; player.spin_same_direction_frames_1324 = 0; @@ -87,6 +97,7 @@ impl Callbacks<'_, '_> { p.flags_2468, self.animation_input.fields.balance, false, + p, ); self.wipeout.state.reset_systems(); self.life.skeleton_controller.effective = 0; @@ -94,10 +105,8 @@ impl Callbacks<'_, '_> { self.life.skeleton_controller.has_request = false; self.life.skeleton_controller.override_enabled = false; self.life.skeleton_controller.flag_18 = false; - self.life.trajectory.pending_request = None; - self.life.trajectory.primary_valid_288 = false; - self.life.trajectory.secondary_valid_592 = false; - self.life.trajectory.result_valid_9840 = false; + //82DB93E8/93EC resets Player1840 through82D749D0, not ballistic work. + self.offboard_grab.invalidate(); player.manager_1856_counter_320 = 0; player.probe = Default::default(); diff --git a/crates/skate-game/src/physics/known_air/runtime.rs b/crates/skate-game/src/physics/known_air/runtime.rs index e7d20621..14172513 100644 --- a/crates/skate-game/src/physics/known_air/runtime.rs +++ b/crates/skate-game/src/physics/known_air/runtime.rs @@ -127,13 +127,16 @@ impl KnownAirRuntime for Runtime<'_> { self.skater.trajectory.selector.grind_locked_to_middle() } fn start_grind_air_adjust_from_selector(&mut self) { - 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; + if let Some(target) = self.selection.grind { + self.skater + .skeleton_input + .grind_air + .start(target.air_target()); + self.skater.skeleton_input.grind_air_started = true; } else { - self.record(Err("KnownAir grind target lost its native spline record".into())); + self.record(Err( + "KnownAir grind lock has no accepted trajectory target".into() + )); } } fn selector_closest_trajectory_point(&mut self, com: V, zero: V) -> (i32, V) { @@ -264,7 +267,7 @@ impl KnownAirRuntime for Runtime<'_> { self.record(result); } fn set_skeleton_add_skateboard_error(&mut self, v: bool) { - self.skater.ground_lifecycle.skeleton_ground_16388 = v; + self.skater.skeleton_output.correction.pending = v; } fn set_head_tracking_target(&mut self, target: V, valid: bool) { self.skater.skeleton_input.head_tracking_history[5] = target; @@ -294,7 +297,7 @@ impl KnownAirRuntime for Runtime<'_> { body: &self.skater.skeleton, gravity: [g.x, g.y, g.z, 0.0], world: &self.physics.world, - edges: &self.physics.grind.primitives, + edges: self.physics.grind_world.primitives(), }; self.skater.footplant.update_candidate(input, &f); } @@ -310,7 +313,7 @@ impl KnownAirRuntime for Runtime<'_> { body: &self.skater.skeleton, gravity: [g.x, g.y, g.z, 0.0], world: &self.physics.world, - edges: &self.physics.grind.primitives, + edges: self.physics.grind_world.primitives(), }; let result = self.skater.footplant.consume_and_submit( input, diff --git a/crates/skate-game/src/physics/landing_on_deck.rs b/crates/skate-game/src/physics/landing_on_deck.rs new file mode 100644 index 00000000..5c626df7 --- /dev/null +++ b/crates/skate-game/src/physics/landing_on_deck.rs @@ -0,0 +1,192 @@ +//! State503 lifecycle from original TU3 82D4D418..82D4E100. +mod publication; +mod skeleton; +use super::{GamePhysics, SkaterRuntime}; +pub(crate) use publication::fill; +use skate_core::{ + physics::{ + skeleton_animation_record::compose_affine, + skeleton_landing_on_board::Settings as RootSettings, + }, + player::{ + offboard::{ + board_possession::lifecycle::Effects as _, + landing_state::{Entry, Settings, State}, + }, + wipeout::Frame, + }, +}; +use skate_data::collections::Collections; +pub(crate) struct Runtime { + pub state: State, + settings: Settings, + root_settings: RootSettings, +} +impl Runtime { + pub fn load(data: &Collections) -> Result { + let get = |name| data.float("physics_landingondeck", "default", name); + Ok(Self { + state: State::default(), + settings: Settings { + minimum_auto_angle: get("MinAngleToAutoTurn")?, + automatic_speed: get("BodySpinSpeedAuto")?, + input_speed: get("BodySpinSpeed")?, + input_delta: get("BodySpinDeltaInput")?, + automatic_delta: get("BodySpinDeltaAuto")?, + maximum_landing_speed: data.float( + "physics_wipeout", + "default", + "MaxSpeedLandingOnBoard", + )?, + }, + root_settings: RootSettings { + root_y_offset: data.float("physics_skeleton", "default", "SkateRootYOffset")?, + capsule_radius: data.float( + "physics_skeleton", + "default", + "SkateRootCapsuleRadius", + )?, + capsule_length: data.float( + "physics_skeleton", + "default", + "SkateRootCapsuleLength", + )?, + }, + }) + } +} +fn clear_ik(skater: &mut SkaterRuntime) { + for limb in &mut skater.foot_ik.state.limbs { + limb.board_blend = 0.; + limb.external_blend = 0.; + limb.mode = skate_core::animation::foot_ik::status::Mode::Disabled; + } + skater.foot_ik.enable_feet(false); +} +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater + .ground_lifecycle + .skeleton_controller + .request(6, &mut skater.skeleton_collision)?; + let p = skater.player_input.processed; + let board = skater + .player_input + .toolkit + .as_ref() + .ok_or("Landing entry requires the completed board toolkit")? + .deck; + let hips = compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &skater.animated_skeleton.record.pose[23], + ); + skater.landing_on_deck.state.enter( + &mut skater.landing_deck.manager, + Entry { + previous_category: p.category_2516, + hippy: p.flags_2480 & 0x1000 != 0, + strength: skater.animation_input.extra.jump_strength, + board_position: board[3], + com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + com_velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + board_velocity: p.vectors_400_416[0].map(f32::from_bits), + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + hips_up: hips[1], + animation_right: p.effective_anim_transform_192[0].map(f32::from_bits), + reversed: p.flags_2476 & 4 != 0, + }, + ); + clear_ik(skater); + let mut effects = skater.board_possession_live.effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + p.timestep_2604, + ); + effects.disable_animation(); + effects.standard_board(); + skater.board_possession_live.publish_volumes(physics); + if skater.landing_on_deck.state.hippy { + skater + .skeleton_output + .trigger_wobble(false, ((p.flags_2468 >> 20) ^ (p.flags_2476 >> 2)) & 1 != 0); + } + Ok(()) +} +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Landing update requires the completed board toolkit")?; + let board = toolkit.deck; + let forward = toolkit.effective[2]; + let scene = super::offboard::contact_toolkit::StaticScene::new(&physics.world) + .map_err(str::to_owned)?; + let output = skater.landing_deck.update(&scene, &skater.player_input)?; + skater.landing_on_deck.state.align( + &skater.landing_on_deck.settings, + forward, + p.effective_anim_transform_192[2].map(f32::from_bits), + p.state_timer_2664, + f32::from_bits(p.vectors_544_560_592_608[3][1]), + skater.animation_input.extra.physical_body_spin, + ); + skeleton::update(physics, skater, output.position)?; + skater.landing_on_deck.state.advance_spin(output); + let manager = &skater.landing_deck.manager; + if manager.trajectory_32.velocity_at(manager.elapsed_160)[1] < 0. { + let time = State::accurate_time( + board[3][1], + f32::from_bits(p.vectors_400_416[0][1]), + f32::from_bits(p.vectors_544_560_592_608[3][1]), + [ + skater.skeleton.record.pose[15][3][1], + skater.skeleton.record.pose[19][3][1], + ], + p.wheel_count_2556, + ); + skater.landing_on_deck.state.time_to_land = time; + skater.landing_on_deck.state.near_deck = time < 0.016000001; + skater.landing_deck.calculate_accurate_ik_offset( + &skater.player_input, + &skater.animated_skeleton, + skater.animated_skeleton.unadjusted_board[3], + time, + )?; + if skater.landing_on_deck.state.near_deck { + skater + .skeleton_output + .trigger_wobble(true, ((p.flags_2468 >> 20) ^ (p.flags_2476 >> 2)) & 1 != 0); + } + } + if skater.animated_skeleton.unadjusted_board[2][2].abs() >= 0.97000003 { + if p.flags_2484 & 2 == 0 && skater.landing_on_deck.state.time_to_land < 0.2 { + skater.foot_ik.enable_feet(true); + } + } else { + clear_ik(skater); + } + skater.landing_on_deck.state.finish( + &skater.landing_on_deck.settings, + f32::from_bits(p.vectors_544_560_592_608[3][1]), + ); + Ok(()) +} +///82D4DF08: synchronize actual query completion before rejection/collision tests. +pub(crate) fn post(skater: &mut SkaterRuntime, frame: Frame) -> Result<(), String> { + skater.landing_deck.post_physics(&skater.player_input)?; + if !skater.landing_deck.fill().can_land_316 { + skater.wipeout.state.request(24, 0.); + } + if skater.landing_on_deck.state.dangerous { + skater.wipeout.state.request(27, 0.); + } + skater + .wipeout + .check_air_collision(&skater.player_input.processed, &frame) +} +///82D4D830 resets only the shared manager; outgoing state data is not another trajectory. +pub(crate) fn exit(_physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.landing_deck.reset(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/landing_on_deck/publication.rs b/crates/skate-game/src/physics/landing_on_deck/publication.rs new file mode 100644 index 00000000..93ec6859 --- /dev/null +++ b/crates/skate-game/src/physics/landing_on_deck/publication.rs @@ -0,0 +1,35 @@ +//! State503 FillPhysOut82D4DFC8. No output is inferred from render transforms. +use super::SkaterRuntime; +pub(crate) fn fill(skater: &mut SkaterRuntime) -> Result<(), String> { + let state = &skater.landing_on_deck.state; + let output = state + .output + .as_ref() + .ok_or("Landing Fill requires its completed Update")?; + let physical = &mut skater.player_input.physical; + let off = &mut physical.off_board; + off.trajectory_valid_331 = u8::from(output.trajectory_valid); + off.scalar_152 = output.elapsed; + off.vector_176 = output.launch_position.map(f32::to_bits); + off.vector_208 = output.landing_position.map(f32::to_bits); + off.scalar_92 = output.landing_time; + off.vector_192 = output.normal.map(f32::to_bits); + off.vector_240 = output.apex_position.map(f32::to_bits); + off.scalar_156 = output.apex_time; + off.vector_224 = output.direction.map(f32::to_bits); + off.vector_160 = output.up.map(f32::to_bits); + off.scalar_32 = state.time_to_land; + off.flag_315 = u8::from(state.near_deck); + off.flag_318 = u8::from(state.turning); + physical.ground.hippy_takeoff_323 = u8::from(state.takeoff_frames > 0); + physical.ground.hippy_jumping_322 = u8::from(state.hippy); + physical.ground.landing_half_turns_312 = state.landing_half_turns(); + skater + .landing_deck + .publish(crate::physics::offboard::landing_deck::PublishTargets { + off_board: off, + skeleton_position_48: &mut physical.collision.vector_48, + skeleton_flag_3482: &mut physical.collision.flag_3482, + }); + Ok(()) +} diff --git a/crates/skate-game/src/physics/landing_on_deck/skeleton.rs b/crates/skate-game/src/physics/landing_on_deck/skeleton.rs new file mode 100644 index 00000000..d352b77d --- /dev/null +++ b/crates/skate-game/src/physics/landing_on_deck/skeleton.rs @@ -0,0 +1,98 @@ +//! Skeleton82BDE428: old-root COM, new root, shared drives, shared reckoning. +use super::{GamePhysics, SkaterRuntime}; +use crate::physics::{ + input_phase, offboard::skeleton_ground::ReckoningUpdate, skeleton_input_runtime::SkeletonOwners, +}; +use skate_core::physics::{ + skeleton_animation_record::{IDENTITY, compose_affine}, + skeleton_landing_on_board as native, +}; +pub(super) fn update( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + position: [f32; 4], +) -> Result<(), String> { + let p = &mut skater.player_input.processed; + let board = skater + .player_input + .toolkit + .as_ref() + .ok_or("Landing skeleton requires the completed board toolkit")? + .deck; + let s = &mut skater.animated_skeleton; + let old = s.roots.animation_to_world; + s.roots.initialize_heading = true; + s.board_frames.skate_root = native::skate_root( + old, + skater.skeleton.record.centre_of_mass, + p.flags_2480, + f32::from_bits(p.vectors_880_896_912_928_944[0][1]), + skater.landing_on_deck.root_settings, + ); + s.board_frames + .update_com_lift(&old, s.board_frames.centre_of_mass, 0.); + s.motion.trajectory = IDENTITY; + //82BE090C..0950 resets only packet bone0; inverse trajectory12112 survives. + let packet = &mut skater.animation.packet; + *packet + .hierarchy + .first_mut() + .ok_or("Landing packet has no trajectory bone")? = IDENTITY; + *packet + .local + .first_mut() + .ok_or("Landing packet has no local trajectory bone")? = IDENTITY; + let revert = + (packet.flags & 0x10000000 != 0).then_some(packet.air_dismount_revert_frames as u32); + native::update_root( + &mut s.roots, + position, + s.record.centre_of_mass, + skater.landing_on_deck.state.applied_spin, + revert, + p.flags_2476 & 4 != 0, + ); + let target = compose_affine( + &s.roots.animation_to_world, + &skater.skeleton_input.drive_frames[0], + ); + s.board_frames.animation_target = target; + p.flags_2468 |= 0x80000; + if p.flags_2480 & 0x8000 != 0 { + s.board_frames.physical_board = + skater + .skeleton_air + .apply_board(&mut physics.board, &target, true); + } + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater.skeleton_input.general_update( + &skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + )?; + skater.biped_ground.finish_reckoning( + ReckoningUpdate { + up: owners.animated.roots.animation_to_world[1], + forward: board[2], + blend: 0.5, + }, + &mut physics.riding, + &mut skater.air_reckoning, + &skater.player_input.processed, + owners.animation_input.extra.physical_body_spin, + ); + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = skater.player_input.processed.flags_2468; + Ok(()) +} diff --git a/crates/skate-game/src/physics/network.rs b/crates/skate-game/src/physics/network.rs index b61b7a48..9b558a9b 100644 --- a/crates/skate-game/src/physics/network.rs +++ b/crates/skate-game/src/physics/network.rs @@ -69,9 +69,9 @@ pub(crate) fn anchors(skater: &SkaterRuntime) -> Vec { } pub(crate) fn capture_body(physics: &GamePhysics, skater: &SkaterRuntime) -> BodyState { let mut enabled = 0u64; - if skater.offboard.board_policy.volumes_enabled { + { for (i, b) in physics.board.bodies().iter().enumerate() { - if b.state_flags != 1 { + if b.state_flags != 1 && skater.board_possession_live.volume_enabled(CollisionBody::Board(skate_core::physics::board::BodyId::ORDER[i])) { enabled |= 1 << i; } } diff --git a/crates/skate-game/src/physics/offboard/air_feet.rs b/crates/skate-game/src/physics/offboard/air_feet.rs new file mode 100644 index 00000000..3d8fb528 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/air_feet.rs @@ -0,0 +1,107 @@ +//! State56 manager updates real foot targets before Skeleton GeneralUpdate. +use crate::physics::SkaterRuntime; +use skate_core::{ + physics::skeleton_animation_record::compose_affine, + player::offboard::biped_air::recovered::feet, +}; +pub(crate) fn update_air( + skater: &mut SkaterRuntime, + manager: &mut skate_core::player::offboard::board_possession::manager::State, +) { + let p = &skater.player_input.processed; + let root = skater.animated_skeleton.roots.animation_to_world; + let mut effective = root; + if p.flags_2476 & 4 != 0 { + for axis in [0, 2] { + effective[axis] = effective[axis].map(|v| -v); + } + } + let local_pairs = [15, 19].map(|index| { + [ + skater.animated_skeleton.record.pose[index][3], + skater.animated_skeleton.record.pose[index + 1][3], + ] + }); + let world_pairs = [15, 19].map(|index| { + [ + compose_affine(&root, &skater.animated_skeleton.record.pose[index])[3], + compose_affine(&root, &skater.animated_skeleton.record.pose[index + 1])[3], + ] + }); + for foot in &mut manager.hands { + foot.scalars_96_100[1] = 0.; + } + manager.words_308_to_316 = [0; 3]; + let targets = feet::update( + manager, + &feet::Input { + lines: std::array::from_fn(|i| { + let line = p.line_tests_960_1008_1056[i]; + feet::Line { + position: line.position.map(f32::from_bits), + normal: line.normal.map(f32::from_bits), + surface: line.surface, + valid: line.valid != 0, + } + }), + root, + inverse_root: skater.animated_skeleton.roots.world_to_animation, + effective_root: effective, + local_foot_pairs: local_pairs, + world_foot_pairs: world_pairs, + position: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + state: p.state_2508, + }, + ); + if p.flags_2484 & 0x80000000 == 0 { + for (index, target) in targets.into_iter().enumerate() { + let status = &mut skater.foot_ik.state.limbs[index]; + let external = &mut skater.foot_ik.state.external_targets[index]; + if target.world { + external.world_position = target.position; + status.external_target_set = true; + } else { + external.animation_position = target.position; + status.local_target_set = true; + } + status.target_blend = target.blend; + if let Some(normal) = target.normal { + feet::set_normal(external, normal); + } + } + } +} + +///82D770D8: retain state56 across500/501/502; clear actual IK reset fields otherwise. +pub(crate) fn enter( + skater: &mut SkaterRuntime, + manager: &mut skate_core::player::offboard::board_possession::manager::State, +) { + let p = &skater.player_input.processed; + if !matches!(p.state_2504, 500 | 501 | 502) { + manager.reset(); + skater.foot_ik.state.enable_feet(false); + for limb in &mut skater.foot_ik.state.limbs { + limb.board_blend = 0.; + limb.external_blend = 0.; + limb.mode = skate_core::animation::foot_ik::status::Mode::Disabled; + } + } + manager.flags_304_to_307[0] = p.state_2508 == 502; +} + +///82D785F8: only these two output bytes belong to the feet service. +pub(crate) fn publish( + manager: &skate_core::player::offboard::board_possession::manager::State, + output: &mut skate_core::player::input_phase::OffBoardOutputFields, +) { + output.flags_306_307 = manager.hands.map(|foot| { + u8::from( + (foot.flags_104_to_107[1] && !foot.flags_104_to_107[2]) || foot.flags_104_to_107[3], + ) + }); +} diff --git a/crates/skate-game/src/physics/offboard/air_selector.rs b/crates/skate-game/src/physics/offboard/air_selector.rs new file mode 100644 index 00000000..c9c42c8c --- /dev/null +++ b/crates/skate-game/src/physics/offboard/air_selector.rs @@ -0,0 +1,200 @@ +//! Concrete BipedAir selector host over the canonical BoardWorld. +//! Parent owns the Air facade and phase schedule. Native completion is explicit, +//! so a synchronous fixed-step backend never adds an automatic frame delay. +mod settings; +#[cfg(test)] +mod tests; +use super::{ + contact_toolkit::StaticScene, + ground_query::{PrimaryEdges, with_world_scene}, +}; +pub(crate) use settings::Settings; +use skate_core::{ + air::trajectory::{Prediction, QueryResult}, + physics::board_world::BoardWorld, + player::offboard::{ + air_launch::Packet, + air_selector::{self as core, Context, Frame, Vector, ledge}, + ground_query::GroundQueryScene, + }, +}; +pub(crate) struct AirSelector { + pub core: core::Selector, + pub settings: Settings, + completed_launch: Option>, + completed_requery: Option, +} +impl AirSelector { + pub(crate) fn reset(&mut self) { + self.core = core::Selector::default(); + self.completed_launch = None; + self.completed_requery = None; + } + + pub(crate) fn new(settings: Settings) -> Self { + Self { + core: core::Selector::default(), + settings, + completed_launch: None, + completed_requery: None, + } + } + ///82D6CA58 submits; results stay staged until consume_launch at native Sync. + pub(crate) fn launch( + &mut self, + world: &BoardWorld, + packet: Packet, + gravity: Vector, + context: Context, + ) -> Result<(), &'static str> { + let mut next = self.core.clone(); + let requests = next + .begin_launch(packet, gravity, self.settings.query) + .inspect_err(|error| { + bevy::log::error!(?packet, ?gravity, settings = ?self.settings.query, %error, + "BipedAir launch producer rejected; no packet fields substituted"); + })?; + let scene = StaticScene::new(world)?; + let results = requests + .into_iter() + .map(|request| { + scene.trajectory(request, context.matching_group_2952, core::MESH_REJECT_MASK) + }) + .collect::, _>>()?; + self.core = next; + self.completed_launch = Some(results); + self.completed_requery = None; + Ok(()) + } + ///82D6CC50/D020/D4D0. The current authored level has no dynamic edge + ///providers; static metadata remains mandatory and is never fabricated. + pub(crate) fn consume_launch( + &mut self, + world: &BoardWorld, + context: Context, + deck_half_wheelbase: f32, + ) -> Result, &'static str> { + let Some(results) = self.completed_launch.as_ref() else { + return Ok(None); + }; + if !self.core.sampling.pending_8492 { + return Ok(None); + } + let mut next = self.core.clone(); + next.observe_launch(results)?; + let first = next.candidates[0]; + let prediction = next.predictions[0]; + let adjustment = if let Some(search) = ledge::search(first, prediction, context) { + let edges = with_world_scene( + world, + PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + &[], + |scene| scene.edge_candidates(&search), + )?; + let filtered = ledge::filter_edges(&edges, first.trajectory.position); + if let Some(adjustment) = ledge::choose( + first, + prediction, + context, + self.settings.query.sphere_radius, + &filtered, + ) { + if !deck_half_wheelbase.is_finite() || deck_half_wheelbase <= 0. { + return Err("BipedAir ledge requires actual positive board half-wheelbase"); + } + if let Some(lines) = ledge::lines(adjustment, deck_half_wheelbase) { + let hits = + StaticScene::new(world)?.lines(&lines, context.matching_group_2952)?; + ledge::consume_lines(&hits)?; + Some(adjustment) + } else { + None + } + } else { + None + } + } else { + None + }; + let index = next.select_launch(context, first, adjustment)?; + self.core = next; + self.completed_launch = None; + Ok(Some(index)) + } + pub(crate) fn adjust_animation(&mut self, frame: i32, animation: Vector, axes: Frame) { + self.core + .adjust_animation(frame, animation, axes, self.settings.query.sphere_radius); + } + pub(crate) fn sample(&mut self, frame: i32, dt: f32, out: &mut core::TrajectoryResult) { + self.core.sample(frame, dt, &self.settings.blend, out); + } + ///82D6E798 permits both pending8492 and8499. Its destination is the same + ///query slot0, so the later requery replaces that slot before consumption. + pub(crate) fn requery( + &mut self, + world: &BoardWorld, + context: Context, + flags_2472: u32, + flags_2488: u32, + ) -> Result { + let mut next = self.core.clone(); + let Some(request) = + next.begin_requery(flags_2472, flags_2488, self.settings.query.sphere_radius)? + else { + return Ok(false); + }; + let result = StaticScene::new(world)?.trajectory( + request, + context.matching_group_2952, + core::MESH_REJECT_MASK, + )?; + self.core = next; + self.completed_requery = Some(Prediction { request, result }); + if let Some(results) = self.completed_launch.as_mut() { + if let Some(first) = results.first_mut() { + *first = result; + } + } + if let Some(first) = self.core.predictions.first_mut() { + *first = Prediction { request, result }; + } + Ok(true) + } + pub(crate) fn consume_requery(&mut self) -> Result { + let Some(prediction) = self.completed_requery else { + return Ok(false); + }; + //CC50 precedesE8A0 and may shift slot0's trajectory/contact time. + let prediction = self.core.predictions.first().copied().unwrap_or(prediction); + self.core.complete_requery(prediction)?; + self.completed_requery = None; + Ok(true) + } + ///82DB3C78: clear ready before launch/requery consumption, then rearm. + ///Call at the original selector consume phase; never use elapsed frames as + ///a synthetic readiness condition for the synchronous world backend. + pub(crate) fn consume( + &mut self, + world: &BoardWorld, + context: Context, + deck_half_wheelbase: f32, + ) -> Result, &'static str> { + self.core.sampling.preinitialized_8494 = false; + let selected = if self.core.sampling.pending_8492 { + self.consume_launch(world, context, deck_half_wheelbase)? + } else { + None + }; + if self.core.requery_pending_8499 { + self.consume_requery()?; + } + self.core.sampling.restart_allowed_8493 = true; + Ok(selected) + } + pub(crate) fn exit(&mut self) { + self.core.exit(); + } +} diff --git a/crates/skate-game/src/physics/offboard/air_selector/settings.rs b/crates/skate-game/src/physics/offboard/air_selector/settings.rs new file mode 100644 index 00000000..82fc1606 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/air_selector/settings.rs @@ -0,0 +1,37 @@ +//! Selector-owned stock settings. Missing authored fields are errors. +#[path = "../settings/curves.rs"] +mod curves; +use skate_core::{player::offboard::air_selector, point_graph::PointGraph}; +use skate_data::collections::Collections; +pub(crate) struct Settings { + pub query: air_selector::Settings, + pub blend: PointGraph<8>, + ///82C209E4: physics_grinds+636, not measured board geometry. + pub deck_center_to_truck: f32, +} +impl Settings { + pub(crate) fn load(data: &Collections) -> Result { + let class = "physics_state_offboard_air"; + let query = air_selector::Settings { + height: data.float(class, "default", "Hash_AB0D9EAEBFC584E9")?, + sphere_radius: data.float(class, "default", "TrajectorySphereRadius")?, + start_index: data.integer(class, "default", "TrajectoryStartIndex")? as i32, + }; + if !query.height.is_finite() + || !query.sphere_radius.is_finite() + || query.sphere_radius <= 0. + { + return Err("Invalid stock BipedAir height/radius".into()); + } + let blend = curves::load::<8>(data, class, "TrajBlendAmountVsTime")?.0; + let deck_center_to_truck = data.float("physics_grinds", "default", "DeckCenterToTruck")?; + if !deck_center_to_truck.is_finite() || deck_center_to_truck <= 0. { + return Err("Invalid stock DeckCenterToTruck".into()); + } + Ok(Self { + query, + blend, + deck_center_to_truck, + }) + } +} diff --git a/crates/skate-game/src/physics/offboard/air_selector/tests.rs b/crates/skate-game/src/physics/offboard/air_selector/tests.rs new file mode 100644 index 00000000..b9634cf1 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/air_selector/tests.rs @@ -0,0 +1,203 @@ +use super::*; +use skate_core::{ + math::Vector3, + physics::{ + board_world::{ + WorldTriangle, + query_metadata::{Bounds, QueryMesh, QueryMetadata, QueryPool}, + }, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + }, + player::offboard::{air_selector::TrajectoryResult, ground_query::Line}, + point_graph::PointGraph, +}; +fn world(pools: &[(QueryPool, i32, u16)], island_flags: u32) -> BoardWorld { + let vertices = [ + Vector3::new(-20., 0., -20.), + Vector3::new(-20., 0., 20.), + Vector3::new(20., 0., 0.), + ]; + let material = RetailContactMaterial { + static_friction: 0.6, + dynamic_friction: 0.4, + restitution: 0.1, + }; + let triangles = pools + .iter() + .map(|_| WorldTriangle::from_vertices(vertices, material, 999, 0, [0.; 3], 0.).unwrap()) + .collect(); + let meshes = pools + .iter() + .enumerate() + .map(|(i, &(pool, group, _))| QueryMesh { + triangle_range: i..i + 1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(vertices).unwrap(), + matching_group: group, + rejection_flags: 0x6000, + geometry: i as u32, + pool, + }) + .collect(); + BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces: pools.iter().map(|p| p.2).collect(), + meshes, + static_edges: vec![], + island_flags, + }, + ) + .unwrap() +} +fn context() -> Context { + Context { + selection_flags_2948: 0, + matching_group_2952: -1, + up_544: [0., 1., 0., 0.], + forward_224: [0., 0., 1., 0.], + } +} +fn owner() -> AirSelector { + AirSelector::new(Settings { + deck_center_to_truck: 0.3, + query: core::Settings { + height: 0.9, + sphere_radius: 0.3, + start_index: 0, + }, + blend: PointGraph { + x: [0.; 8], + y: [1.; 8], + }, + }) +} +fn packet() -> Packet { + let mut p = Packet::initialized(0.); + p.position_32 = [0., 2., 0., 0.]; + p.velocity_0 = [0., 0., 3., 0.]; + p.forward_64 = [0., 0., 1., 0.]; + p +} +#[test] +fn six_real_lines_keep_native_pool_ties_and_exact_surfaces() { + let world = world( + &[(QueryPool::Ground, 2, 0x411), (QueryPool::Island, 2, 0x722)], + 0, + ); + let scene = StaticScene::new(&world).unwrap(); + let line = Line { + start: Vector3::new(0., 1., 0.), + end: Vector3::new(0., -1., 0.), + radius: 0., + }; + let hits = scene.lines(&[line; 6], 2).unwrap(); + assert_eq!(hits.len(), 6); + for hit in hits { + let hit = hit.unwrap(); + assert_eq!(hit.packed_surface, 0x411); + assert!((hit.fraction - 0.5).abs() < 1e-5); + } + assert!( + scene + .lines(&[line; 6], 3) + .unwrap() + .iter() + .all(Option::is_none) + ); + //Mesh0x6000 does not reject these lines; unlike trajectory their mask is0. +} +#[test] +fn conditional_pool_requires_exact_island_three() { + let line = Line { + start: Vector3::new(0., 1., 0.), + end: Vector3::new(0., -1., 0.), + radius: 0., + }; + for flags in [0, 1, 2, 3, 7] { + let world = world(&[(QueryPool::Conditional, -1, 0x845)], flags); + let result = StaticScene::new(&world) + .unwrap() + .lines(&[line], -1) + .unwrap(); + assert_eq!(result[0].is_some(), flags == 3); + } +} +#[test] +fn line_rejects_nonfinite_input_without_synthesizing_a_hit() { + let world = world(&[(QueryPool::Ground, -1, 1)], 0); + let scene = StaticScene::new(&world).unwrap(); + let line = Line { + start: Vector3::new(f32::NAN, 1., 0.), + end: Vector3::ZERO, + radius: 0., + }; + assert!(scene.lines(&[line], -1).is_err()); + assert!(scene.lines(&[], -1).unwrap().is_empty()); +} +#[test] +fn synchronous_results_remain_staged_until_native_consume() { + let world = world(&[(QueryPool::Ground, -1, 1)], 0); + let mut owner = owner(); + let mut out = TrajectoryResult::default(); + owner + .launch(&world, packet(), [0., -9.8, 0., 0.], context()) + .unwrap(); + assert!(owner.core.sampling.pending_8492); + owner.sample(0, core::DT, &mut out); + assert!(!out.valid_404); + assert_eq!(out.position_272, packet().position_32); + assert_eq!(owner.consume(&world, context(), 0.4).unwrap(), Some(0)); + assert!(!owner.core.sampling.pending_8492); + assert!(owner.core.sampling.preinitialized_8494); + //The actual mask0x6000 rejects the plane in trajectory requests. + assert!(!owner.core.selected_candidate.valid_120); + assert!(owner.requery(&world, context(), 0, 0).unwrap()); + assert!(owner.core.requery_pending_8499); + owner.consume(&world, context(), 0.4).unwrap(); + assert!(!owner.core.requery_pending_8499); + assert!(owner.core.sampling.restart_allowed_8493); + assert!(!owner.core.sampling.preinitialized_8494); +} +#[test] +fn failed_world_submission_preserves_owner_and_never_returns_fake_completion() { + let world = BoardWorld::new(vec![]); + let mut owner = owner(); + assert!( + owner + .launch(&world, packet(), [0., -9.8, 0., 0.], context()) + .is_err() + ); + assert!(!owner.core.sampling.pending_8492); + assert!(owner.completed_launch.is_none()); + assert!(owner.completed_requery.is_none()); +} +#[test] +fn launch_and_requery_share_slot_zero_and_consume_in_native_order() { + let world = world(&[(QueryPool::Ground, -1, 1)], 0); + let mut owner = owner(); + owner + .launch(&world, packet(), [0., -9.8, 0., 0.], context()) + .unwrap(); + assert!(owner.requery(&world, context(), 0, 0).unwrap()); + assert!(owner.core.sampling.pending_8492 && owner.core.requery_pending_8499); + assert!(owner.completed_launch.is_some() && owner.completed_requery.is_some()); + assert!(!owner.requery(&world, context(), 0, 0).unwrap()); + assert_eq!(owner.consume(&world, context(), 0.3).unwrap(), Some(0)); + assert!(!owner.core.sampling.pending_8492 && !owner.core.requery_pending_8499); + assert!(owner.completed_launch.is_none() && owner.completed_requery.is_none()); + assert!(owner.core.sampling.restart_allowed_8493); +} +#[test] +fn native_suppression_returns_before_pending_host_completion() { + let world = world(&[(QueryPool::Ground, -1, 1)], 0); + let mut owner = owner(); + owner + .launch(&world, packet(), [0., -9.8, 0., 0.], context()) + .unwrap(); + assert!(!owner.requery(&world, context(), 0x10000000, 0).unwrap()); + assert!(!owner.requery(&world, context(), 0, 0x400000).unwrap()); + assert!(owner.core.sampling.pending_8492); +} diff --git a/crates/skate-game/src/physics/offboard/board_manager.rs b/crates/skate-game/src/physics/offboard/board_manager.rs new file mode 100644 index 00000000..68ea17d1 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_manager.rs @@ -0,0 +1,180 @@ +//! Persistent SkateboardController adapter, not the grab-spline query manager. +//! +//! TU3 82D74FD8 creates two hand/deck drives; 82DB6150 updates possession +//! after the physical state's Update and before the common board update. +//! State448/word444/system452 are borrowed from the existing controller. Never +//! create another copy in SkaterRuntime, or infer ownership from graph flags. +use super::board_possession::{self, Effects}; +use skate_core::{ + physics::{assembly::BodySnapshot, drive_solver::RetailDriveRows}, + player::{ + lifecycle::{SkateboardControllerActions, SkateboardControllerFields}, + offboard::board_possession::{ + self as native, Frame, + lifecycle::{Observation, Settings}, + }, + }, +}; +use skate_data::collections::Collections; +pub(crate) mod runtime; + +#[cfg(test)] +mod tests; + +pub(crate) struct Owner { + pub state: native::State, + settings: Settings, +} + +impl Owner { + pub(crate) fn load(data: &Collections) -> Result { + Ok(Self { + state: native::State::default(), + settings: board_possession::settings::load(data)?, + }) + } + + /// Call once after the selected physical state Update. Observations must + /// come from the evaluated skeleton and completed contact publications. + pub(crate) fn update( + &mut self, + fields: &mut SkateboardControllerFields, + observation: &Observation, + effects: &mut Effects<'_>, + ) { + self.state + .update(fields, observation, &self.settings, effects); + } + + pub(crate) fn hold( + &mut self, + fields: &mut SkateboardControllerFields, + observation: &Observation, + effects: &mut Effects<'_>, + ) { + self.state.hold(fields, observation, effects); + } + + /// LetGo does not assign state448: the physical-state lifecycle or + /// UpdateState caller does that after disabling the physical drives. + pub(crate) fn let_go( + &mut self, + fields: &mut SkateboardControllerFields, + observation: &Observation, + effects: &mut Effects<'_>, + ) { + self.state + .let_go(fields, observation, &self.settings, effects); + } + + pub(crate) fn stop( + &mut self, + fields: &mut SkateboardControllerFields, + observation: &Observation, + effects: &mut Effects<'_>, + ) { + self.state + .stop(fields, observation, &self.settings, effects); + } + + ///82DB8CE4..8D1C and82DB8D6C..8DA4: clear countdown even when + ///already off, Stop, then74D30 resets retrieval only (not hand frames). + pub(crate) fn reset_for_teleport( + &mut self, + fields: &mut SkateboardControllerFields, + observation: &Observation, + effects: &mut Effects<'_>, + ) { + fields.word_444 = 0; + self.stop(fields, observation, effects); + self.state.retrieval = native::Retrieval::default(); + } + + /// Append to the existing island's drive rows, with the SAME reaction + /// indices used for skeleton part3/7 and deck6 by the contact/joint passes. + /// There is no separate board simulation and no render-bone attachment. + pub(crate) fn append_drives( + &self, + deck: BodySnapshot, + hands: [BodySnapshot; 2], + deck_reaction: usize, + hand_reactions: [usize; 2], + dt: f32, + rows: &mut Vec, + ) { + board_possession::drives::append( + &self.state, + deck, + hands, + deck_reaction, + hand_reactions, + dt, + rows, + ); + } + + /// 82D76D20: pass the real Skeleton::GetBoneTransform(11), not a hand, + /// root, deck frame or bind-pose substitute. + pub(crate) fn fill( + &self, + fields: &SkateboardControllerFields, + processed: &native::Processed, + bone_11: Frame, + ) -> native::Fill { + native::fill(fields, &self.state, processed, bone_11) + } +} + +/// SetPhysicsState owns a mutable StateChangeData while invoking callbacks. +/// This short-lived adapter borrows the real physical owners, and retains only +/// the pre-transition controller fields needed by LetGo (notably RETURNING). +/// It executes effects immediately; it is not a deferred event queue. +pub(crate) struct Transition<'a, 'board> { + pub owner: &'a mut Owner, + pub observation: &'a Observation, + pub effects: &'a mut Effects<'board>, + previous: SkateboardControllerFields, + changed: bool, +} + +impl<'a, 'board> Transition<'a, 'board> { + pub(crate) fn new( + owner: &'a mut Owner, + observation: &'a Observation, + effects: &'a mut Effects<'board>, + previous: SkateboardControllerFields, + ) -> Self { + Self { + owner, + observation, + effects, + previous, + changed: false, + } + } + + /// Merge only the throw countdown. The caller's state448/system452 writes + /// must survive. LetGo sets word444=8 for the TU3 throw bit; discarding it + /// would lose the subsequent eight UpdateFreeBoard torque applications. + pub(crate) fn finish(self, fields: &mut SkateboardControllerFields) { + if self.changed { + fields.word_444 = self.previous.word_444; + } + } +} + +impl SkateboardControllerActions for Transition<'_, '_> { + fn hold_skateboard(&mut self) { + self.previous.word_444 = 0; + self.owner + .hold(&mut self.previous, self.observation, self.effects); + self.changed = true; + } + + fn let_go_of_skateboard(&mut self) { + self.previous.word_444 = 0; + self.owner + .let_go(&mut self.previous, self.observation, self.effects); + self.changed = true; + } +} diff --git a/crates/skate-game/src/physics/offboard/board_manager/runtime.rs b/crates/skate-game/src/physics/offboard/board_manager/runtime.rs new file mode 100644 index 00000000..f90065c7 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_manager/runtime.rs @@ -0,0 +1,274 @@ +//! Live bindings for TU3 SkateboardController, 82DB6150 and 82D76D20. +use super::super::board_possession::{Effects, Materials, VolumeFlags, settings, vector}; +use super::native; +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::{ + physics::{ + board::BodyId, board_step::CollisionBody, contact::RetailContactMaterial, + skeleton_animation_record::compose_affine, + }, + player::offboard::board_possession::{ + Fill, + lifecycle::{Alignment, Observation}, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct LiveState { + pub volumes: VolumeFlags, + alignment: Alignment, + alignment_active: bool, + standard_materials: [RetailContactMaterial; 3], + released_material: RetailContactMaterial, + standard_drag: f32, + pub output: Option, +} +impl LiveState { + pub(crate) fn load(data: &Collections, physics: &GamePhysics) -> Result { + let s = &physics.settings; + let friction = data.float("physics_wipeout", "default", "SkateboardFriction")?; + Ok(Self { + volumes: VolumeFlags { + deck: true, + trucks: s.truck_collisions, + wheels: true, + deck_children: s + .deck_geometry + .children + .iter() + .map(|c| c.collision_enabled) + .collect(), + }, + alignment: Alignment { + first_1008: [0.; 4], + second_1024: [0.; 4], + factor_1040: 0., + flag_1044: false, + }, + alignment_active: false, + standard_materials: [s.standard_wheel_material, s.truck_material, s.deck_material], + released_material: RetailContactMaterial { + static_friction: friction, + dynamic_friction: friction, + restitution: data.float("physics_wipeout", "default", "SkateboardRestitution")?, + }, + standard_drag: settings::standard_angular_drag(data)?, + output: None, + }) + } + + pub(crate) fn effects<'a>( + &'a mut self, + physics: &'a mut GamePhysics, + animated: &'a mut u8, + dt: f32, + ) -> Effects<'a> { + Effects { + board: &mut physics.board, + animated_290: animated, + wiping_out: &mut physics.board_wiping_out, + alignment: &mut self.alignment, + alignment_active: &mut self.alignment_active, + volumes: &mut self.volumes, + materials: Materials { + wheels: &mut physics.settings.wheel_material, + trucks: &mut physics.settings.truck_material, + deck: &mut physics.settings.deck_material, + }, + standard_materials: self.standard_materials, + released_material: self.released_material, + standard_angular_drag: self.standard_drag, + processed_dt_2604: dt, + } + } + + /// The compound children and shared truck shape are the actual fields + /// colliders::world_volumes consumes. Wheel/aggregate gates are applied in + /// solve.rs before either world queries or board/skater pairing. + pub(crate) fn publish_volumes(&self, physics: &mut GamePhysics) { + physics.settings.truck_collisions = self.volumes.trucks; + for (child, enabled) in physics + .settings + .deck_geometry + .children + .iter_mut() + .zip(&self.volumes.deck_children) + { + child.collision_enabled = *enabled; + } + } + pub(crate) fn volume_enabled(&self, body: CollisionBody) -> bool { + match body { + CollisionBody::Board(BodyId::Deck) => self.volumes.deck, + CollisionBody::Board(BodyId::FrontTruck | BodyId::BackTruck) => self.volumes.trucks, + CollisionBody::Board(_) => self.volumes.wheels, + _ => true, + } + } +} + +pub(crate) fn observe(physics: &GamePhysics, skater: &SkaterRuntime) -> Observation { + let p = &skater.player_input.processed; + let frames = &skater.skeleton_input.drive_frames; + let mut board = crate::physics::solve::deck_frame(&physics.board); + //82C01440..60: Processed64 is the unmodified deck part transform. + //The effective axes are written separately at320/352. + if let Some(toolkit) = &skater.player_input.toolkit { + board = toolkit.deck; + } + Observation { + processed: native::Processed { + board_frame_64: board, + player_frame_192: p + .effective_anim_transform_192 + .map(|v| v.map(f32::from_bits)), + position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + velocity_912: p.vectors_880_896_912_928_944[2].map(f32::from_bits), + direction_400: p.vectors_400_416[0].map(f32::from_bits), + hide_direction_464: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2488: p.flags_2488, + }, + board_collision_flags_872: physics.riding.ground.collision_flags, + board_state_840: surface(physics), + //82D761F0 reads Collision1232 + part*112 +99 (group byte1331). + hand_contacts: [ + skater.collision_feedback.bones[3].groups[3], + skater.collision_feedback.bones[7].groups[3], + ], + physical_hand_positions: [ + skater.skeleton.record.pose[3][3], + skater.skeleton.record.pose[7][3], + ], + animation_board_frame_12624: frames[0], + animation_hand_frames: [frames[3], frames[7]], + //82BE3170 composes Skeleton11920 with current12624; not physical pose. + attachment_frame_0: compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &frames[0], + ), + } +} + +///82C08818 votes only four wheel surfaces; physical contact counts four, +/// line-only support counts one. Ties retain the lowest nonzero surface. +fn surface(physics: &GamePhysics) -> u32 { + if physics.riding.ground.collision_flags & 0x0200_0000 != 0 { + return 12; + } + let mut counts = [0; 32]; + for i in 0..4 { + let surface = physics.riding.wheel_lines.physics_surfaces[i] as usize; + if surface != 0 { + counts[surface] += if physics.riding.ground.parts[i].in_contact { + 4 + } else { + 1 + }; + } + } + let mut best = 1; + let mut count = 0; + for i in 1..=13 { + if counts[i] > count { + best = i; + count = counts[i]; + } + } + best as u32 +} + +/// Insert immediately after frame.rs's selected physical-state Update match, +/// before state_timer_1344 and solve. TU3 82DB6150..61B0. +pub(crate) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + if !skater.skateboard_controller.fields.system_on_452 { + return; + } + let observation = observe(physics, skater); + let mut effects = skater.board_possession_live.effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ); + skater.board_possession.update( + &mut skater.skateboard_controller.fields, + &observation, + &mut effects, + ); + skater.board_possession_live.publish_volumes(physics); +} + +///82DB8998 brackets board/skeleton teleport with this operation. Call before +///board.reset_physical, then again after Skeleton update_teleport. +pub(crate) fn reset_for_teleport(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + let observation = observe(physics, skater); + let mut effects = skater.board_possession_live.effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ); + skater.board_possession.reset_for_teleport( + &mut skater.skateboard_controller.fields, + &observation, + &mut effects, + ); + skater.board_possession_live.output = None; + skater.board_possession_live.publish_volumes(physics); +} + +///82DB8E10..8E24, AFTER teleport's SetPhysicsState(Ground). Standard-board +///restoration must follow Stop's released-material writes, not precede them. +pub(crate) fn finish_teleport(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + use native::lifecycle::Effects as _; + skater + .board_possession_live + .effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ) + .standard_board(); + skater.board_possession_live.publish_volumes(physics); +} + +/// Insert after finish_skater (completed contacts), before next input. The +/// alignment test is82C0837C..8428 and publishes the actual release bit872. +pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Fill { + let live = &skater.board_possession_live; + if live.alignment_active { + use native::math::dot; + for part in &physics.riding.ground.parts { + if part.in_contact + && (dot(vector(part.normal), live.alignment.first_1008.map(|x| -x)) + > live.alignment.factor_1040 + || dot(vector(part.normal), live.alignment.second_1024.map(|x| -x)) + > live.alignment.factor_1040) + { + physics.riding.ground.collision_flags |= 0x0400_0000; + } + } + } + let observation = observe(physics, skater); + let bone = compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &skater.skeleton_input.drive_frames[11], + ); + let out = skater.board_possession.fill( + &skater.skateboard_controller.fields, + &observation.processed, + bone, + ); + let physical = &mut skater.player_input.physical.off_board; + physical.angle_36 = out.angle_36; + physical.angle_40 = out.angle_40; + physical.flag_311 = u8::from(out.held_311); + physical.free_board_312 = u8::from(out.free_312); + physical.returning_board_313 = u8::from(out.returning_313); + physical.hiding_board_321 = u8::from(out.hiding_321); + physical.dropping_board_322 = u8::from(out.flag_322); + physical.retrieving_board_323 = u8::from(out.flag_323); + physical.flag_324 = u8::from(out.flag_324); + skater.board_possession_live.output = Some(out); + out +} diff --git a/crates/skate-game/src/physics/offboard/board_manager/tests.rs b/crates/skate-game/src/physics/offboard/board_manager/tests.rs new file mode 100644 index 00000000..5e7d785f --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_manager/tests.rs @@ -0,0 +1,319 @@ +//! Adapter tests use synthetic physical parameters, not stock parity fixtures. +//! They check writes to actual BoardRuntime bodies/drives rather than a call log. +use super::*; +use board_possession::{Materials, VolumeFlags}; +use native::{IDENTITY, lifecycle::Alignment}; +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::{BoardMotion, BoardRuntime}, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + rigid_body::{ + RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame, + RetailSimulationStep, + }, + }, + point_graph::PointGraph, +}; + +fn owner() -> Owner { + let graph = |value| PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [value; 8], + }; + Owner { + state: native::State::default(), + settings: Settings { + hide_distance: 30., + hide_offset: 1000., + return_distance: 30., + mounted_return_distance: 5., + mounting_time: 0.1, + retrieval_time: graph(1.), + retrieval_weight: graph(0.5), + throw_pitch: 18., + throw_velocity: graph(2.), + throw_target_pitch: 20., + throw_pitch_scalar: 0.1, + throw_roll_scalar: 0.1, + throw_yaw_scalar: 0.2, + }, + } +} + +fn observation() -> Observation { + Observation { + processed: native::Processed { + board_frame_64: IDENTITY, + player_frame_192: IDENTITY, + position_592: [0.; 4], + velocity_912: [0.; 4], + direction_400: [0., 0., 1., 0.], + hide_direction_464: [0., 0., 1., 0.], + flags_2476: 0, + flags_2480: 0, + flags_2488: 0, + }, + board_collision_flags_872: 0, + board_state_840: 0, + hand_contacts: [false; 2], + physical_hand_positions: [[0.; 4]; 2], + animation_board_frame_12624: IDENTITY, + animation_hand_frames: [IDENTITY; 2], + attachment_frame_0: IDENTITY, + } +} + +fn board() -> BoardRuntime { + let mass = RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(1., 1., 1.), + inverse_mass: 1., + spherical: 0., + maximum_linear_velocity: 100., + maximum_angular_velocity: 100., + linear_drag: 0., + angular_drag: 0., + }, + }; + BoardRuntime::new( + [mass; 7], + [RetailAffineTransform::IDENTITY; 7], + RetailAffineTransform::IDENTITY, + RetailSimulationStep::fixed_60_hz(0, 0., Vector3::ZERO), + BoardMotion::Active, + ) +} + +#[test] +fn hold_and_release_change_real_drives_without_repositioning_board() { + let mut owner = owner(); + let mut board = board(); + let original = board.part_transforms(); + for body in board.bodies_mut() { + body.rates.linear_velocity = Vector3::new(1., 2., 3.); + } + let mut fields = SkateboardControllerFields { + state_448: 0, + word_444: 0, + system_on_452: true, + }; + let observation = observation(); + let standard = RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.4, + restitution: 0.1, + }; + let released = RetailContactMaterial { + static_friction: 0.2, + dynamic_friction: 0.1, + restitution: 0.3, + }; + let (mut animated, mut wiping_out, mut aligned) = (0, false, false); + let mut alignment = Alignment { + first_1008: [0.; 4], + second_1024: [0.; 4], + factor_1040: 0., + flag_1044: false, + }; + let mut volumes = VolumeFlags { + deck: true, + trucks: true, + wheels: true, + deck_children: vec![true; 2], + }; + let [mut wheels, mut trucks, mut deck] = [standard; 3]; + let mut effects = Effects { + board: &mut board, + animated_290: &mut animated, + wiping_out: &mut wiping_out, + alignment_active: &mut aligned, + alignment: &mut alignment, + volumes: &mut volumes, + materials: Materials { + wheels: &mut wheels, + trucks: &mut trucks, + deck: &mut deck, + }, + standard_materials: [standard; 3], + released_material: released, + standard_angular_drag: 3., + processed_dt_2604: 1. / 60., + }; + owner.hold(&mut fields, &observation, &mut effects); + assert_eq!(owner.state.selected_hand_424, 1); + assert_eq!(owner.state.hands[0].dynamics, [[0, 0, 0, 2]; 2]); + assert_eq!( + owner.state.hands[1].dynamics, + [[0x4395ffff, 0, 0x468c9fff, 2]; 2] + ); + assert_eq!(*effects.animated_290, 1); + assert_eq!(effects.board.part_transforms(), original); + + // Real hand and deck body IDs must be preserved in the shared row builder. + let mut rows = Vec::new(); + let bodies = effects.board.bodies(); + owner.append_drives( + bodies[6], + [bodies[0], bodies[1]], + 6, + [11, 15], + 1. / 60., + &mut rows, + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].frame_a_body.reaction_index, 11); + assert_eq!(rows[1].frame_a_body.reaction_index, 15); + assert!(rows.iter().all(|row| row.frame_b_body.reaction_index == 6)); + + owner.let_go(&mut fields, &observation, &mut effects); + assert_eq!( + fields.state_448, 1, + "LetGo leaves the caller's state assignment alone" + ); + assert_eq!(owner.state.selected_hand_424, 2); + assert!( + owner + .state + .hands + .iter() + .all(|hand| hand.dynamics == [[0, 0, 0, 2]; 2]) + ); + assert_eq!( + effects.board.hook_mut().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2] + ); + assert_eq!(*effects.animated_290, 0); + assert!(*effects.wiping_out); + assert_eq!(*effects.materials.wheels, released); + assert_eq!(*effects.materials.trucks, released); + assert_eq!(*effects.materials.deck, released); + assert_eq!(effects.board.part_transforms(), original); + assert!( + effects + .board + .bodies() + .iter() + .all(|body| body.rates.linear_velocity == Vector3::new(1., 2., 3.)) + ); + + // Returning is the explicit exception: release clears retrieval velocity. + fields.state_448 = 4; + owner.let_go(&mut fields, &observation, &mut effects); + assert!( + effects + .board + .bodies() + .iter() + .all(|body| body.rates.linear_velocity == Vector3::ZERO) + ); + + // SetPhysicsState changes state448 itself. The callback must preserve + // LetGo's throw countdown without restoring the old returning state. + let mut throw_observation = observation; + throw_observation.processed.flags_2476 = 0x1000; + let mut transition = Transition::new(&mut owner, &throw_observation, &mut effects, fields); + transition.let_go_of_skateboard(); + fields.state_448 = 0; + fields.system_on_452 = false; + fields.word_444 = 0; + transition.finish(&mut fields); + assert_eq!(fields.state_448, 0); + assert!(!fields.system_on_452); + assert_eq!(fields.word_444, 8); + assert!( + effects + .board + .bodies() + .iter() + .all(|body| body.rates.linear_velocity.z > 0.) + ); + + //Teleport74D30 resets retrieval, not the persistent drive allocation. + fields.system_on_452 = true; + owner.hold(&mut fields, &throw_observation, &mut effects); + owner.state.retrieval.elapsed_192 = 4.; + owner.reset_for_teleport(&mut fields, &throw_observation, &mut effects); + assert_eq!(fields.state_448, 0); + assert!(!fields.system_on_452); + assert_eq!(fields.word_444, 8, "Stop retains LetGo's throw write"); + assert_eq!(owner.state.selected_hand_424, 2); + assert_eq!(owner.state.retrieval.elapsed_192, 0.); + assert_eq!(owner.state.retrieval.initial_0, IDENTITY); + owner.reset_for_teleport(&mut fields, &throw_observation, &mut effects); + assert_eq!( + fields.word_444, 0, + "second teleport reset clears it while off" + ); + native::lifecycle::Effects::standard_board(&mut effects); + assert!(!*effects.wiping_out); + assert_eq!(*effects.materials.deck, standard); + assert!(effects.volumes.deck && effects.volumes.trucks && effects.volumes.wheels); + + //75F00 ->75440 ->764F0: release must disable physical coupling BEFORE + //the throw velocity and subsequent eight free-board torque updates. + fields.system_on_452 = true; + let mut throwing = throw_observation; + throwing.processed.flags_2476 = 0x3000; + owner.hold(&mut fields, &throwing, &mut effects); + owner.update(&mut fields, &throwing, &mut effects); + assert_eq!(fields.state_448, 2); + assert_eq!(fields.word_444, 7); + assert_eq!(owner.state.selected_hand_424, 2); + let mut rows = Vec::new(); + let bodies = effects.board.bodies(); + owner.append_drives( + bodies[6], + [bodies[0], bodies[1]], + 6, + [11, 15], + 1. / 60., + &mut rows, + ); + assert_eq!( + rows.len(), + 2, + "native drives remain allocated when disabled" + ); + for row in &rows { + assert_eq!(row.linear_maximum_impulse, [0.; 3]); + assert_eq!(row.angular_maximum_impulse, [0.; 3]); + } + let mut reactions = [skate_core::physics::rigid_body::RetailReactionCorrections::default(); 16]; + skate_core::physics::drive_solver::solve_drive_rows(&mut rows, &mut reactions, 8); + assert_eq!( + reactions, + [Default::default(); 16], + "released hands cannot pull either assembly" + ); + for _ in 0..7 { + owner.update(&mut fields, &throwing, &mut effects); + } + assert_eq!(fields.word_444, 0); + let velocities = effects + .board + .bodies() + .map(|body| body.rates.linear_velocity); + owner.update(&mut fields, &throwing, &mut effects); + assert_eq!(fields.state_448, 2); + assert_eq!( + fields.word_444, 0, + "throw input in FREE does not restart the throw" + ); + assert_eq!( + effects + .board + .bodies() + .map(|body| body.rates.linear_velocity), + velocities + ); + assert!( + owner + .state + .hands + .iter() + .all(|h| h.dynamics == [[0, 0, 0, 2]; 2]) + ); +} diff --git a/crates/skate-game/src/physics/offboard/board_possession.rs b/crates/skate-game/src/physics/offboard/board_possession.rs new file mode 100644 index 00000000..bf7a0805 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_possession.rs @@ -0,0 +1,164 @@ +//! Live board controller adapter. The caller borrows the existing board and +//! state448 owners; this module never advances a second physics island. +pub(crate) mod drives; +pub(crate) mod settings; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board_runtime::BoardRuntime, contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + }, + player::offboard::board_possession::{ + Frame, Vector, angular, + lifecycle::{Alignment, Effects as NativeEffects}, + }, +}; + +/// Three shared shapes: deck aggregate A064, truck capsule A87C used by both +/// trucks A8B0/A8FC, wheel sphere AB94 assigned to all four wheels AC44. +/// Initialize from the actual authored shape flags, then collision generation +/// reads these live flags instead of immutable construction settings. +pub(crate) struct VolumeFlags { + pub deck: bool, + pub trucks: bool, + pub wheels: bool, + pub deck_children: Vec, +} + +/// Borrow the actual three material slots read by colliders.rs. A detached +/// seven-element copy is not a live collision publication in this host. +pub(crate) struct Materials<'a> { + pub wheels: &'a mut RetailContactMaterial, + pub trucks: &'a mut RetailContactMaterial, + pub deck: &'a mut RetailContactMaterial, +} +impl Materials<'_> { + fn set(&mut self, values: [RetailContactMaterial; 3]) { + *self.wheels = values[0]; + *self.trucks = values[1]; + *self.deck = values[2]; + } +} +impl VolumeFlags { + pub(crate) fn set_enabled(&mut self, enabled: bool) { + self.deck = enabled; + self.trucks = enabled; + self.wheels = enabled; + self.deck_children.fill(enabled); + } +} + +pub(crate) struct Effects<'a> { + pub board: &'a mut BoardRuntime, + pub animated_290: &'a mut u8, + pub wiping_out: &'a mut bool, + pub alignment_active: &'a mut bool, + pub alignment: &'a mut Alignment, + pub volumes: &'a mut VolumeFlags, + pub materials: Materials<'a>, + /// Wheels, trucks, deck, from their original standard-material settings. + pub standard_materials: [RetailContactMaterial; 3], + pub released_material: RetailContactMaterial, + ///091F8: actual8D3065CED858A7DF field multiplied by822F860C. + pub standard_angular_drag: f32, + pub processed_dt_2604: f32, +} +impl NativeEffects for Effects<'_> { + fn enable_animation_soft(&mut self) { + self.board + .hook_mut() + .drive + .enable_animation_soft(self.animated_290); + } + fn enable_animation_angular_only(&mut self) { + self.board + .hook_mut() + .drive + .enable_angular_only(self.animated_290); + } + fn disable_animation(&mut self) { + self.board + .hook_mut() + .drive + .disable_animation(self.animated_290); + } + fn disable_linear_drive(&mut self) { + self.board.hook_mut().drive.disable_linear(); + } + fn standard_board(&mut self) { + *self.wiping_out = false; + self.board.bodies_mut()[6].inertia.angular_drag = self.standard_angular_drag; + self.board.set_collision_group(4); + self.materials.set(self.standard_materials); + self.collision_volumes(true); + } + fn released_board(&mut self) { + *self.wiping_out = true; + self.board.bodies_mut()[6].inertia.angular_drag = f32::from_bits(0x416fffff); + self.board.set_collision_group(7); + self.materials.set([self.released_material; 3]); + self.collision_volumes(true); + } + fn collision_volumes(&mut self, enabled: bool) { + self.volumes.set_enabled(enabled); + } + fn clear_alignment(&mut self) { + *self.alignment_active = false; + } + fn alignment(&mut self, value: Alignment) { + *self.alignment = value; + *self.alignment_active = true; + } + fn velocity(&mut self, value: Vector) { + for body in self.board.bodies_mut() { + body.rates.linear_velocity = xyz(value); + } + } + fn position(&mut self, value: Vector) { + let mut target = self.board.part_transforms()[6]; + target.translation = xyz(value); + self.board.set_transform(target); + } + fn hook_frame(&mut self, value: Frame) { + self.board.set_hook_transform(affine(value)); + } + fn target_position_velocity(&mut self, value: Vector) { + let center = self.board.bodies()[6].rates.position; + let delta = [ + value[0] - center.x, + value[1] - center.y, + value[2] - center.z, + 0., + ]; + self.velocity(delta.map(|x| x * (1. / self.processed_dt_2604))); + } + fn torque(&mut self, value: Vector) { + let deck = &mut self.board.bodies_mut()[6]; + let delta = angular::acceleration_delta( + value, + vector(deck.rates.angular_velocity), + deck.rates + .world_inverse_inertia + .columns + .map(|v| [v[0], v[1], v[2], 0.]), + ); + let a = deck.rates.torque_acceleration; + deck.rates.torque_acceleration = + Vector3::new(a.x + delta[0], a.y + delta[1], a.z + delta[2]); + deck.rates.cool_down = 0; + } +} +pub(crate) fn xyz(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +pub(crate) fn vector(v: Vector3) -> Vector { + [v.x, v.y, v.z, 0.] +} +pub(crate) fn affine(v: Frame) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: std::array::from_fn(|i| [v[i][0], v[i][1], v[i][2]]), + }, + translation: xyz(v[3]), + } +} diff --git a/crates/skate-game/src/physics/offboard/board_possession/drives.rs b/crates/skate-game/src/physics/offboard/board_possession/drives.rs new file mode 100644 index 00000000..72c73a78 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_possession/drives.rs @@ -0,0 +1,74 @@ +//!74FD8 creates both persistent hand drives. AddDrive reverses argument bodies: +//! child/internal A is skeleton part3/7; parent/internal B is deck6. +use super::affine; +use skate_core::{ + physics::{ + assembly::BodySnapshot, + drive_frames::{RetailDriveFrame, RetailDriveFrames, retail_quaternion_from_basis}, + drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}, + drive_solver::{RetailDriveBodyState, RetailDriveRows, build_drive_rows}, + rigid_body::pack_world_inverse_inertia, + skeleton_body::prepare_bone_drive_frames, + }, + player::offboard::board_possession::State, +}; +pub(crate) fn append( + state: &State, + deck: BodySnapshot, + hands: [BodySnapshot; 2], + deck_reaction: usize, + hand_reactions: [usize; 2], + dt: f32, + rows: &mut Vec, +) { + for i in 0..2 { + if (deck.state_flags | hands[i].state_flags) & 4 == 0 { + continue; + } + let drive = state.hands[i]; + let frame = |v| { + let v = affine(v); + RetailDriveFrame { + orientation: retail_quaternion_from_basis(v.basis), + translation: v.translation, + } + }; + let params = |v: [u32; 4]| RetailDriveParams { + spring_or_max_velocity: f32::from_bits(v[0]), + damping: f32::from_bits(v[1]), + max_strength: f32::from_bits(v[2]), + drive_type: RetailDriveType::HardDrive, + }; + rows.push(build_drive_rows( + body(hands[i], hand_reactions[i]), + body(deck, deck_reaction), + //82763BC4..3C44 normalizes both quaternions of every registered + //drive before Jacobian construction, including possession drives. + prepare_bone_drive_frames(RetailDriveFrames { + body_a: frame(drive.child), + body_b: frame(drive.parent), + }), + RetailDriveDynamics { + linear: params(drive.dynamics[0]), + angular: params(drive.dynamics[1]), + }, + dt, + )); + } +} +fn body(b: BodySnapshot, reaction_index: usize) -> RetailDriveBodyState { + let r = b.rates; + RetailDriveBodyState { + reaction_index, + state: b.state_flags, + orientation: r.orientation, + basis: r.basis, + center_of_mass: r.position, + linear_velocity: r.linear_velocity, + angular_velocity: r.angular_velocity, + force_acceleration: r.force_acceleration, + torque_acceleration: r.torque_acceleration, + inverse_mass: b.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(r.world_inverse_inertia), + } +} diff --git a/crates/skate-game/src/physics/offboard/board_possession/settings.rs b/crates/skate-game/src/physics/offboard/board_possession/settings.rs new file mode 100644 index 00000000..251d8ba8 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/board_possession/settings.rs @@ -0,0 +1,47 @@ +//! Constructor74DD8 class7DEA05DC70A1ED7D/defaultD7EDBD362D7D2152. +use skate_core::{ + player::offboard::board_possession::lifecycle::Settings, point_graph::PointGraph, +}; +use skate_data::collections::Collections; +///091F8 full hash8D3065CED858A7DF, literal822F860C=426FFFFF. +pub(crate) fn standard_angular_drag(data: &Collections) -> Result { + Ok(data.float("physicsdeck", "default", "DeckAngularDrag")? * f32::from_bits(0x426fffff)) +} +pub(crate) fn load(data: &Collections) -> Result { + let float = |name| data.float("physics_skatecontroller", "default", name); + Ok(Settings { + hide_distance: float("MaxDistance")?, + hide_offset: float("DistanceToHideSkateboard")?, + return_distance: float("DistanceForBoardReturn")?, + mounted_return_distance: float("DistanceForMountedReturn")?, + mounting_time: float("MinTimeToRetrieveBoardWhileMounting")?, + retrieval_time: curve(data, "RetrievalTimeVsDist")?, + retrieval_weight: curve(data, "RetrievalSpeedCurve")?, + throw_pitch: float("ThrownSkateboardLaunchPitch")?, + throw_velocity: curve(data, "ThrownSkateboardVelocity")?, + throw_target_pitch: float("ThrownSkateboardPitchTarget")?, + throw_pitch_scalar: float("ThrownSkateboardPitchScalar")?, + throw_roll_scalar: float("ThrownSkateboardRollScalar")?, + throw_yaw_scalar: float("ThrownSkateboardYawScalar")?, + }) +} +fn curve(data: &Collections, name: &str) -> Result, String> { + let f = data.field("physics_skatecontroller", "default", name)?; + if f.type_name != "Sk8::PointNegGraphData8" { + return Err(format!("{name}: expected PointNegGraphData8")); + } + let words = data + .words::<20>("physics_skatecontroller", "default", name)? + .map(f32::from_bits); + if words.iter().any(|v| !v.is_finite()) { + return Err(format!("{name}: nonfinite stock graph")); + } + let x = std::array::from_fn(|i| words[4 + i]); + if x.windows(2).any(|p| p[0] > p[1]) { + return Err(format!("{name}: unordered stock knots")); + } + Ok(PointGraph { + x, + y: std::array::from_fn(|i| words[12 + i]), + }) +} diff --git a/crates/skate-game/src/physics/offboard/collision_settings.rs b/crates/skate-game/src/physics/offboard/collision_settings.rs new file mode 100644 index 00000000..d4130dc6 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/collision_settings.rs @@ -0,0 +1,18 @@ +//! Stock physics_wipeout fields consumed by Biped collision check82D90148. +//! Retain this once with the physical player's off-board settings. +use skate_core::player::offboard::ground_lifecycle::CollisionSettings; +use skate_data::collections::Collections; + +pub(crate) fn load(data: &Collections) -> Result { + let value = |name| data.float("physics_wipeout", "default", name); + Ok(CollisionSettings { + vehicle_scalar: value("Wipeout_OB_VehicleScalar")?, + vehicle_contact: value("Wipeout_OB_VehicleContact")?, + maximum_displacement: value("Wipeout_OB_SkeletonMaxDisp")?, + maximum_arm_contact: value("Wipeout_OB_SkeletonMaxContactArms")?, + maximum_body_contact: value("Wipeout_OB_SkeletonMaxContact")?, + minimum_speed: value("Wipeout_OB_MinSpeed")?, + maximum_squash: value("Wipeout_OB_MaxSquash")?, + special_scalar: value("Hash_472174920C68FBE3")?, + }) +} diff --git a/crates/skate-game/src/physics/offboard/contact_queries.rs b/crates/skate-game/src/physics/offboard/contact_queries.rs index 09c3d090..9db11187 100644 --- a/crates/skate-game/src/physics/offboard/contact_queries.rs +++ b/crates/skate-game/src/physics/offboard/contact_queries.rs @@ -7,9 +7,15 @@ use skate_core::{ drive_frames::RetailAffineTransform, triangle_query::{TriangleLineHit, triangle_segment}, }, - player::offboard::contact_queries::{Input, Layout, Probe}, }; +#[derive(Clone, Copy)] +pub(crate) struct Probe { + pub start: [f32; 4], + pub end: [f32; 4], + pub radius: f32, +} + #[derive(Clone, Copy, Debug)] pub(crate) struct Hit { pub geometry: TriangleLineHit, @@ -18,103 +24,6 @@ pub(crate) struct Hit { pub support_frame: RetailAffineTransform, } -pub(crate) struct Completed { - pub input: Input, - pub support: [Option; 3], - pub obstacles: Vec>, -} - -/// Connect actual canonical scene observations to the recovered classifier. -pub(crate) fn submit_toolkit( - world: &BoardWorld, - layout: &Layout, - input: Input, - matching_id: u32, -) -> Result { - use skate_core::player::offboard::{ - contact_packet::SupportHit, contact_toolkit, ground_query::Edge, - }; - let completed = submit(world, layout, input, matching_id)?; - let v = |p: Vector3| [p.x, p.y, p.z, 0.]; - let support = completed.support.map(|h| { - h.map(|h| { - let f = h.support_frame; - let b = f.basis.columns; - SupportHit { - position: v(h.geometry.position), - normal: v(h.geometry.normal), - frame: [ - [b[0][0], b[0][1], b[0][2], 0.], - [b[1][0], b[1][1], b[1][2], 0.], - [b[2][0], b[2][1], b[2][2], 0.], - v(f.translation), - ], - // Guest query136 identifies the supporting body. Canonical mesh - // identities replace guest pointers in this authored static scene. - support_id: u32::try_from(h.mesh_index + 1) - .expect("canonical map mesh identity fits u32"), - } - }) - }); - let obstacles: Vec<_> = completed - .obstacles - .into_iter() - .map(|h| { - h.map(|h| contact_toolkit::Hit { - position: v(h.geometry.position), - normal: v(h.geometry.normal), - }) - }) - .collect(); - let obstacles = obstacles.try_into().map_err(|_: Vec<_>| { - "Biped query provider did not complete all44 native query slots".to_owned() - })?; - let metadata = world.query_metadata().map_err(str::to_owned)?; - let (min, max) = contact_toolkit::edge_bounds(input); - let bounds = skate_core::physics::board_world::query_metadata::Bounds { - min: xyz(min), - max: xyz(max), - }; - // This BoardWorld owns an authored static scene; it has no dynamic/vehicle - // edge providers. Preserve static provider order and the native shared cap. - let edges = metadata - .static_edges - .iter() - .filter(|edge| edge.local_bounds.overlaps(bounds)) - .take(40) - .map(|edge| Edge { - start: edge.start, - end: edge.end, - }) - .collect(); - Ok(contact_toolkit::Observations { - input, - support, - obstacles, - edges, - }) -} - -pub(crate) fn submit( - world: &BoardWorld, - layout: &Layout, - input: Input, - matching_id: u32, -) -> Result { - let (support, obstacles) = layout.world_probes(input); - let support = support.map(|probe| query(world, probe, matching_id)); - let [a, b, c] = support; - let mut hits = vec![None; obstacles.iter().map(|p| p.id + 1).max().unwrap_or(0)]; - for probe in obstacles { - hits[probe.id] = query(world, probe, matching_id)?; - } - Ok(Completed { - input, - support: [a?, b?, c?], - obstacles: hits, - }) -} - fn xyz(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } @@ -191,70 +100,3 @@ pub(crate) fn query(world: &BoardWorld, probe: Probe, matching_id: u32) -> Resul } Ok(nearest) } - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn authored_floor_and_empty_space_produce_real_support_observations() { - let world = - crate::physics::ground::world(skate_core::physics::contact::RetailContactMaterial { - static_friction: 0., - dynamic_friction: 0., - restitution: 0., - }); - let mut input = Input { - position: [0., 0., 0., 0.], - surface_right: [1., 0., 0., 0.], - surface_up: [0., 1., 0., 0.], - surface_forward: [0., 0., 1., 0.], - animation_right: [1., 0., 0., 0.], - animation_up: [0., 1., 0., 0.], - velocity: [0.; 4], - }; - let layout = Layout::default(); - let hits = submit(&world, &layout, input, 0).unwrap(); - assert!(hits.support.iter().all(Option::is_some)); - assert!(hits.obstacles.iter().any(Option::is_some)); - assert_eq!(hits.support[0].unwrap().packed_surface, 0); - input.position[0] = 1000.; - let hits = submit(&world, &layout, input, 0).unwrap(); - assert!(hits.support.iter().all(Option::is_none)); - assert!(hits.obstacles.iter().all(Option::is_none)); - } - #[test] - fn real_floor_queries_run_through_the_complete_contact_classifier() { - use skate_core::player::offboard::contact_toolkit::Toolkit; - let world = - crate::physics::ground::world(skate_core::physics::contact::RetailContactMaterial { - static_friction: 0., - dynamic_friction: 0., - restitution: 0., - }); - let layout = Layout::default(); - let mut toolkit = Toolkit::default(); - for speed in [0., 1., 3., 6.] { - let input = Input { - position: [0.; 4], - surface_right: [1., 0., 0., 0.], - surface_up: [0., 1., 0., 0.], - surface_forward: [0., 0., 1., 0.], - animation_right: [1., 0., 0., 0.], - animation_up: [0., 1., 0., 0.], - velocity: [0., 0., speed, 0.], - }; - toolkit.submit(submit_toolkit(&world, &layout, input, 0).unwrap()); - toolkit.refresh(&layout); - assert_eq!( - toolkit.packet.flags & 3, - 3, - "speed{speed}: {:?}", - toolkit.packet - ); - assert!(toolkit.packet.target_position.iter().all(|v| v.is_finite())); - assert_eq!(toolkit.packet.kind_164, 0); - assert!(toolkit.pending.is_none()); - assert_eq!(toolkit.contact_age, 30); - } - } -} diff --git a/crates/skate-game/src/physics/offboard/contact_toolkit.rs b/crates/skate-game/src/physics/offboard/contact_toolkit.rs new file mode 100644 index 00000000..10dac989 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/contact_toolkit.rs @@ -0,0 +1,8 @@ +//! Static canonical-world adapter for OffboardGroundAnalyzer. +//! The runtime must supply actual mesh metadata; neither a board contact nor +//! a synthetic ground ray can provide it. +#[cfg(test)] +mod tests; +mod world; +pub use skate_core::player::offboard::contact_toolkit::Owner; +pub use world::StaticScene; diff --git a/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs b/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs new file mode 100644 index 00000000..08c98507 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/contact_toolkit/tests.rs @@ -0,0 +1,224 @@ +use super::*; +use skate_core::{ + math::Vector3, + physics::{ + board_world::{ + BoardWorld, WorldTriangle, + query_metadata::{Bounds, QueryMesh, QueryMetadata, QueryPool}, + }, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + }, + player::offboard::contact_toolkit::{Input, ProbeLayout, Scene}, +}; +fn plane(rejection_flags: u32) -> BoardWorld { + let vertices = [ + Vector3::new(-10., 0., -10.), + Vector3::new(-10., 0., 10.), + Vector3::new(10., 0., 0.), + ]; + let material = RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 0., + }; + let triangle = WorldTriangle::from_vertices(vertices, material, 999, 0, [0.; 3], 0.).unwrap(); + BoardWorld::with_query_metadata( + vec![triangle], + QueryMetadata { + packed_surfaces: vec![17], + meshes: vec![QueryMesh { + triangle_range: 0..1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(vertices).unwrap(), + matching_group: -1, + rejection_flags, + geometry: 73, + pool: QueryPool::Ground, + }], + static_edges: vec![], + island_flags: 0, + }, + ) + .unwrap() +} +fn input() -> Input { + Input::from_vectors([ + [0.; 4], + [0., 0., 1., 0.], + [0., 1., 0., 0.], + [1., 0., 0., 0.], + [0.; 4], + [0., 1., 0., 0.], + [1., 0., 0., 0.], + ]) +} +#[test] +fn actual_world_hit_preserves_mesh_identity_and_packed_surface() { + let world = plane(0); + let scene = StaticScene::new(&world).unwrap(); + let result = scene + .execute(&ProbeLayout::stock().prepare(input(), -1)) + .unwrap(); + assert!(result.trajectories[0].valid()); + assert_eq!(result.trajectories[0].geometry, 73); + assert_eq!(result.trajectories[0].surface, 17); + assert!((result.trajectories[0].landing_normal[1] - 1.).abs() < 1e-5); + assert_eq!(result.lines.len(), 44); +} +#[test] +fn mesh_filter_is_not_a_board_flag_or_material_tag() { + let world = plane(0x2000); + let scene = StaticScene::new(&world).unwrap(); + let result = scene + .execute(&ProbeLayout::stock().prepare(input(), -1)) + .unwrap(); + assert!(!result.trajectories[0].valid()); + assert!(result.lines.iter().all(Option::is_none)); +} + +#[test] +fn canonical_world_reaches_completed_analyzer_without_substitute_support() { + let world = plane(0); + let scene = StaticScene::new(&world).unwrap(); + let mut owner = Owner::default(); + assert!(owner.refresh().is_none()); + owner.submit(input(), -1, &scene).unwrap(); + let completed = owner.refresh().unwrap(); + assert_eq!(completed.prefix.flags_176 & 1, 1); + assert_eq!(completed.prefix.support_180, 73); + assert!(completed.prefix.position[1].abs() < 1e-5); + assert!(completed.prefix.normal[1] > 0.99); + assert!(completed.samples.original_ground_count > 0); + assert!(!owner.has_pending()); + + // Move the query outside the authored collision mesh. Previous support + // cannot be retained as a fabricated surface after the real query misses. + let mut airborne = input(); + airborne.position[1] = 10.; + owner.submit(airborne, -1, &scene).unwrap(); + let missed = owner.refresh().unwrap(); + assert_eq!(missed.prefix.flags_176 & 1, 0); + assert_eq!(missed.prefix.support_180, 0); + assert!(missed.results.trajectories.iter().all(|hit| !hit.valid())); +} + +#[test] +fn toolkit_refuses_world_without_authored_query_metadata() { + let unannotated = BoardWorld::new(Vec::new()); + assert!(StaticScene::new(&unannotated).is_err()); +} + +#[test] +fn embedded_static_rwcm_hits_distinct_actor_query_ids() { + let mut map = skate_data::skate_map::SkateMap::parse(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../maps/format-demo.skate" + ))) + .unwrap(); + map.geometry.collision.clear(); + map.extensions.push(skate_data::skate_map::Extension { + tag: *b"RWCM", + schema: 1, + payload: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../skate-data/tests/fixtures/retail-collision.rwcmset" + )) + .to_vec(), + }); + let world = crate::skate_world::collision_world( + &map, + RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 1., + }, + ) + .unwrap(); + assert!( + world + .query_metadata() + .unwrap() + .meshes + .iter() + .all(|mesh| mesh.matching_group == -1 && mesh.geometry == 0) + ); + let tri = &world.triangles()[1].triangle; + let [a, b, c] = tri.vertices; + let centre = Vector3::new( + (a.x + b.x + c.x) / 3., + (a.y + b.y + c.y) / 3., + (a.z + b.z + c.z) / 3., + ); + let normal = tri.feature.normal; + let line = skate_core::player::offboard::ground_query::Line { + start: Vector3::new( + centre.x + normal.x, + centre.y + normal.y, + centre.z + normal.z, + ), + end: Vector3::new( + centre.x - normal.x, + centre.y - normal.y, + centre.z - normal.z, + ), + radius: 0., + }; + let scene = StaticScene::new(&world).unwrap(); + //Native static registration uses mesh matchingID-1, not packed unit group0x1234. + for actor in [0, 0x1234, 73] { + assert!( + scene.lines(&[line], actor).unwrap()[0].is_some(), + "Static RWCM unit group incorrectly filtered actor{actor}" + ); + } +} + +#[test] +fn indexed_clusters_preserve_rejection_identity_surface_and_pool_ties() { + let source = plane(0); + let mut triangles = Vec::new(); + let mut metadata = source.query_metadata().unwrap().clone(); + metadata.meshes.clear(); + metadata.packed_surfaces.clear(); + for i in 0..256 { + let shift = if (100..=102).contains(&i) { + 0. + } else { + 1000. + i as f32 * 30. + }; + let original = source.triangles()[0]; + let vertices = original + .triangle + .vertices + .map(|v| Vector3::new(v.x + shift, v.y, v.z)); + triangles.push( + WorldTriangle::from_vertices(vertices, original.material, 999, 0, [0.; 3], 0.).unwrap(), + ); + let mut mesh = source.query_metadata().unwrap().meshes[0].clone(); + mesh.triangle_range = i..i + 1; + mesh.local_bounds = Bounds::from_points(vertices).unwrap(); + mesh.geometry = 7000 + i as u32; + mesh.rejection_flags = if i == 100 { 0x2000 } else { 0 }; + mesh.pool = if i == 102 { + QueryPool::Island + } else { + QueryPool::Ground + }; + metadata.meshes.push(mesh); + metadata.packed_surfaces.push(i as u16); + } + let world = BoardWorld::with_query_metadata(triangles, metadata).unwrap(); + let scene = StaticScene::new(&world).unwrap(); + let result = scene + .execute(&ProbeLayout::stock().prepare(input(), -1)) + .unwrap(); + assert!(result.trajectories[0].valid()); + assert_eq!(result.trajectories[0].geometry, 7101); + assert_eq!(result.trajectories[0].surface, 101); + for hit in result.lines.iter().flatten() { + assert_eq!(hit.geometry, 7101); + assert_eq!(hit.surface, 101); + } +} diff --git a/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs b/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs new file mode 100644 index 00000000..55649dce --- /dev/null +++ b/crates/skate-game/src/physics/offboard/contact_toolkit/world.rs @@ -0,0 +1,392 @@ +//!82770B40/82771D08 mesh traversal and82770E00/82772028 nearby collection. +//!This adapter only accepts BoardWorld's authored static-world contract. +use skate_core::{ + air::trajectory::{QueryRequest, QueryResult, SurfaceHit, query_trajectory}, + math::Vector3, + physics::{ + board_world::{ + BoardWorld, + query_metadata::{QueryMetadata, QueryPool}, + }, + triangle_query::{TriangleLineHit, triangle_segment}, + }, + player::offboard::contact_toolkit::{ + Batch, LineHit, LineProbe, QueryResults, Scene, Vector, query_sweep, + }, +}; + +pub struct StaticScene<'a> { + world: &'a BoardWorld, + metadata: &'a QueryMetadata, +} +impl<'a> StaticScene<'a> { + ///82C20728/82764AB0: caller-sized lines, poolmask7, facing3, no mesh or + ///material exclusion. In particular Air submits SIX lines, Ground seven. + pub(crate) fn lines( + &self, + requests: &[skate_core::player::offboard::ground_query::Line], + matching_group: i32, + ) -> Result>, &'static str> + { + use skate_core::player::offboard::ground_query::LineHit as GroundHit; + let mut output: Vec> = vec![None; requests.len()]; + for pool in self.pools() { + for (request, destination) in requests.iter().zip(&mut output) { + let probe = LineProbe { + start: lanes(request.start), + end: lanes(request.end), + radius: request.radius, + }; + if let Some(hit) = self.line(pool, probe, matching_group, 0)? { + if destination + .as_ref() + .is_none_or(|old| hit.fraction < old.fraction) + { + *destination = Some(GroundHit { + position: vec3(hit.position), + face_normal: vec3(hit.normal), + fraction: hit.fraction, + packed_surface: hit.surface, + }); + } + } + } + } + Ok(output) + } + /// 82764CF8 pool mask 7; 82770910 walks the actual accelerated trajectory. + /// 8276EE88 retains the first pool on equal contact times. Callers supply + /// the actor matching group and the original per-request mesh rejection mask. + pub(crate) fn trajectory( + &self, + request: QueryRequest, + matching_group: i32, + mesh_reject_mask: u32, + ) -> Result { + let t = request.trajectory; + if !t.duration.is_finite() + || t.duration <= 0. + || !request.radius.is_finite() + || request.radius <= 0. + || !request.start_error.is_finite() + || request.start_error <= 0. + || !request.end_error.is_finite() + || request.end_error <= 0. + || t.position + .into_iter() + .chain(t.velocity) + .chain(t.acceleration) + .any(|value| !value.is_finite()) + { + return Err("Invalid offboard accelerated-trajectory request"); + } + let mut nearest = QueryResult::miss(); + for pool in self.pools() { + let result = query_trajectory( + request, + |start, end, radius| { + self.line( + pool, + LineProbe { start, end, radius }, + matching_group, + mesh_reject_mask, + ) + .map(|hit| { + hit.map(|h| SurfaceHit { + position: h.position, + normal: h.normal, + transform: h.mesh_frame, + surface: h.surface as u32, + geometry: h.geometry, + }) + }) + }, + |center, radius| self.nearby(pool, center, radius, matching_group), + )?; + if result.valid() && (!nearest.valid() || result.contact_time < nearest.contact_time) { + nearest = result; + } + } + Ok(nearest) + } + pub fn new(world: &'a BoardWorld) -> Result { + let metadata = world.query_metadata()?; + Ok(Self { world, metadata }) + } + fn pools(&self) -> impl Iterator { + [ + Some(QueryPool::Ground), + Some(QueryPool::Island), + (self.metadata.island_flags == 3).then_some(QueryPool::Conditional), + ] + .into_iter() + .flatten() + } + fn line( + &self, + pool: QueryPool, + probe: LineProbe, + group: i32, + reject: u32, + ) -> Result, &'static str> { + if !probe.radius.is_finite() + || probe.radius < 0. + || probe + .start + .into_iter() + .chain(probe.end) + .any(|x| !x.is_finite()) + { + // Temporary audit-only error detail: preserve the rejected producer + // packet and caller chain. No substitution or validation change. + bevy::log::error!( + "OFFBOARD_INVALID_SWEEP start={:?} end={:?} radius={} start_bits={:08x?} end_bits={:08x?} radius_bits={:08x} group={} reject={:08x} caller={}", + probe.start, + probe.end, + probe.radius, + probe.start.map(f32::to_bits), + probe.end.map(f32::to_bits), + probe.radius.to_bits(), + group, + reject, + std::backtrace::Backtrace::force_capture(), + ); + return Err("Invalid offboard swept-line request"); + } + let delta = sub(probe.end, probe.start); + if !delta[..3] + .iter() + .any(|x| x.abs() > f32::from_bits(0x37800000)) + { + return Ok(None); + } + let start = vec3(probe.start); + let direction = vec3(delta); + let mut nearest = f32::MAX; + let mut output = None; + let bounds = self + .world + .line_candidate_bounds(start, vec3(probe.end), probe.radius); + let candidates: Vec<_> = self + .world + .line_candidates(start, vec3(probe.end), probe.radius) + .map(|(index, _)| index) + .collect(); + for mesh_index in self.world.candidate_mesh_indices(bounds)? { + let mesh = &self.metadata.meshes[mesh_index]; + if mesh.pool != pool + || !matches(group, mesh.matching_group) + || mesh.rejection_flags & reject != 0 + { + continue; + } + let first = candidates.partition_point(|&i| i < mesh.triangle_range.start); + let last = candidates.partition_point(|&i| i < mesh.triangle_range.end); + for &index in &candidates[first..last] { + let vertices = self.world.triangles()[index].triangle.vertices; + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + if !triangle_segment(&mut hit, start, direction, vertices, probe.radius, 0.) { + continue; + } + let lower = if -hit.fraction >= 0. { + 0. + } else { + hit.fraction + }; + let fraction = if 1. - lower >= 0. { lower } else { 1. }; + if fraction < nearest { + nearest = fraction; + let transform = mesh.local_to_world; + output = Some(LineHit { + position: lanes(hit.position), + normal: face(vertices.map(lanes)), + fraction, + surface: self.metadata.packed_surfaces[index], + geometry: mesh.geometry, + mesh_frame: [ + vector4(transform.basis.columns[0]), + vector4(transform.basis.columns[1]), + vector4(transform.basis.columns[2]), + lanes(transform.translation), + ], + }); + } + } + } + Ok(output) + } + fn nearby( + &self, + pool: QueryPool, + center: Vector, + radius: f32, + group: i32, + ) -> Result, &'static str> { + let mut output = Vec::with_capacity(64); + //82770E00 does not apply the trajectory mesh rejection mask here. + let bounds = self + .world + .line_candidate_bounds(vec3(center), vec3(center), radius); + let candidates: Vec<_> = self + .world + .line_candidates(vec3(center), vec3(center), radius) + .map(|(index, _)| index) + .collect(); + for mesh_index in self.world.candidate_mesh_indices(bounds)? { + let mesh = &self.metadata.meshes[mesh_index]; + if mesh.pool != pool || !matches(group, mesh.matching_group) { + continue; + } + let first = candidates.partition_point(|&i| i < mesh.triangle_range.start); + let last = candidates.partition_point(|&i| i < mesh.triangle_range.end); + for &index in &candidates[first..last] { + let vertices = self.world.triangles()[index].triangle.vertices.map(lanes); + if triangle_box(vertices, center, radius) { + output.push(vertices); + if output.len() == 64 { + return Ok(output); + } + } + } + } + Ok(output) + } +} +impl Scene for StaticScene<'_> { + type Error = &'static str; + fn execute(&self, batch: &Batch) -> Result { + let mut trajectories = [QueryResult::miss(); 3]; + let mut lines = vec![None; batch.lines.len()]; + for pool in self.pools() { + //8276EE88 chooses strict smallest nonnegative time, retaining the + //first pool on ties. Normal refinement belongs to that same pool. + for (request, output) in batch.trajectories.iter().zip(&mut trajectories) { + let start = request.trajectory.position; + let end = std::array::from_fn(|i| start[i] + request.trajectory.velocity[i]); + let result = query_sweep( + LineProbe { + start, + end, + radius: request.radius, + }, + |probe| self.line(pool, probe, batch.matching_group, batch.mesh_reject_mask), + |center, radius| self.nearby(pool, center, radius, batch.matching_group), + )?; + if result.valid() && (!output.valid() || result.contact_time < output.contact_time) + { + *output = result; + } + } + for (probe, output) in batch.lines.iter().zip(&mut lines) { + if let Some(hit) = + self.line(pool, *probe, batch.matching_group, batch.mesh_reject_mask)? + { + if output + .as_ref() + .is_none_or(|old: &LineHit| hit.fraction < old.fraction) + { + *output = Some(hit); + } + } + } + } + Ok(QueryResults { + trajectories, + lines, + edges: self.edges(batch), + }) + } +} +impl StaticScene<'_> { + /// 82D81610 bounds and 82C1EAD8 ordinary static-edge traversal/capacity. + fn edges(&self, batch: &Batch) -> Vec<[Vector; 2]> { + let input = batch.input; + let center: Vector = std::array::from_fn(|i| { + input.up[i].mul_add( + 0.29999998, + input.forward[i].mul_add(0.89999998, input.position[i]), + ) + }); + let extent: Vector = std::array::from_fn(|i| { + input.forward[i].abs().mul_add( + 0.89999998, + input.up[i] + .abs() + .mul_add(1.1, input.right[i].abs() * 0.037500001), + ) + }); + self.metadata + .static_edges + .iter() + .filter(|edge| { + let min = lanes(edge.local_bounds.min); + let max = lanes(edge.local_bounds.max); + (0..3).all(|i| center[i] - extent[i] <= max[i] && min[i] <= center[i] + extent[i]) + }) + .take(40) + .map(|edge| [lanes(edge.start), lanes(edge.end)]) + .collect() + } +} +fn vector4(v: [f32; 3]) -> Vector { + [v[0], v[1], v[2], 0.] +} +fn matches(a: i32, b: i32) -> bool { + a == -1 || b == -1 || a == b +} +fn vec3(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> Vector { + [v.x, v.y, v.z, 0.] +} +fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn dot(a: Vector, b: Vector) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0., + ] +} +fn face([a, b, c]: [Vector; 3]) -> Vector { + skate_core::player::offboard::contact_toolkit::triangle_normal([a, b, c]) +} +///82761698: full edge-cross-axis, coordinate-axis, and face-plane separation. +///AABB overlap alone must not select a face for normal refinement. +fn triangle_box(vertices: [Vector; 3], center: Vector, radius: f32) -> bool { + let v = vertices.map(|p| sub(p, center)); + let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])]; + let axes = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.]]; + let separated = |axis: Vector| { + let p = v.map(|x| dot(x, axis)); + let extent = axis[2].abs().mul_add( + radius, + axis[1].abs().mul_add(radius, axis[0].abs() * radius), + ); + p[0].min(p[1]).min(p[2]) > extent || p[0].max(p[1]).max(p[2]) < -extent + }; + for edge in edges { + for axis in axes { + if separated(cross(edge, axis)) { + return false; + } + } + } + for axis in axes { + if separated(axis) { + return false; + } + } + !separated(cross(edges[0], edges[1])) +} diff --git a/crates/skate-game/src/physics/offboard/grab_scene.rs b/crates/skate-game/src/physics/offboard/grab_scene.rs new file mode 100644 index 00000000..cb2e745c --- /dev/null +++ b/crates/skate-game/src/physics/offboard/grab_scene.rs @@ -0,0 +1,80 @@ +//! Stateless offboard grab-scene execution over explicitly authored objects. +//! Ground owns pending/completion, validation and selection; this owns no ticks. +mod collision; +pub(crate) use native::{Descriptor, Hit, Line, Object, Query, Record}; +use skate_core::{physics::board_world::BoardWorld, player::offboard::grab_scene as native}; + +///Explicit collision mesh -> physical assembly relation, not identity guessing. +#[derive(Clone, Copy, Debug)] +pub(crate) struct MeshAssembly { + pub mesh: u32, + pub assembly: u32, +} + +pub(crate) struct Registry { + pub objects: native::Registry, + mesh_assemblies: Vec, +} +impl Registry { + pub(crate) fn new( + world: &BoardWorld, + objects: Vec, + mesh_assemblies: Vec, + ) -> Result { + let metadata = world.query_metadata().map_err(str::to_owned)?; + let objects = native::Registry::new(objects).map_err(str::to_owned)?; + for (index, binding) in mesh_assemblies.iter().enumerate() { + if binding.assembly == 0 || !metadata.meshes.iter().any(|m| m.geometry == binding.mesh) + { + return Err("Grab collision binding must name an existing mesh and nonzero physical assembly".into()); + } + if mesh_assemblies[..index] + .iter() + .any(|prior| prior.mesh == binding.mesh) + { + return Err("One canonical collision mesh cannot name two assemblies".into()); + } + } + Ok(Self { + objects, + mesh_assemblies, + }) + } + fn assembly(&self, mesh: u32) -> Option { + self.mesh_assemblies + .iter() + .find(|b| b.mesh == mesh) + .map(|b| b.assembly) + } +} +pub(crate) struct Scene<'a> { + world: &'a BoardWorld, + registry: &'a Registry, +} +impl<'a> Scene<'a> { + pub(crate) fn new(world: &'a BoardWorld, registry: &'a Registry) -> Self { + Self { world, registry } + } + pub(crate) fn query(&self, query: &Query) -> Result, String> { + native::query(&self.registry.objects, query).map_err(str::to_owned) + } + ///8275F8D8 dispatches ONLY descriptor kinds1/2. Resolve the actual authored + ///spline geometry and CURRENT physical transform, not an old query result. + pub(crate) fn resolve(&self, descriptor: Descriptor) -> Result, String> { + if !matches!(descriptor.kind, 1 | 2) { + return Ok(None); + } + for object in &self.registry.objects.objects { + if let Some(spline) = object.splines.iter().find(|s| s.descriptor == descriptor) { + return object.record(spline).map(Some).map_err(str::to_owned); + } + } + Ok(None) + } + pub(crate) fn line(&self, line: Line) -> Result, String> { + collision::line(self, line) + } + pub(crate) fn eligible_object(&self, hit: Hit) -> Option { + self.registry.objects.eligible_object(hit) + } +} diff --git a/crates/skate-game/src/physics/offboard/grab_scene/collision.rs b/crates/skate-game/src/physics/offboard/grab_scene/collision.rs new file mode 100644 index 00000000..8531ccfa --- /dev/null +++ b/crates/skate-game/src/physics/offboard/grab_scene/collision.rs @@ -0,0 +1,105 @@ +//! Actual static scene swept triangles. Assembly identity comes exclusively +//! from registered physical associations, never the mesh/triangle numeric ID. +use super::{Hit, Line, Scene}; +use skate_core::{ + math::Vector3, + physics::{ + board_world::query_metadata::QueryPool, + triangle_query::{TriangleLineHit, triangle_segment}, + }, +}; +pub(super) fn line(scene: &Scene<'_>, line: Line) -> Result, String> { + if line.radius < 0.0 + || !line.radius.is_finite() + || line + .start + .into_iter() + .chain(line.end) + .any(|v| !v.is_finite()) + { + return Err("Invalid grab validation swept line".into()); + } + let delta = std::array::from_fn::<_, 4, _>(|i| line.end[i] - line.start[i]); + if !delta[..3] + .iter() + .any(|v| v.abs() > f32::from_bits(0x37800000)) + { + return Ok(None); + } + let metadata = scene.world.query_metadata().map_err(str::to_owned)?; + let bounds = scene + .world + .line_candidate_bounds(xyz(line.start), xyz(line.end), line.radius); + let meshes = scene + .world + .candidate_mesh_indices(bounds) + .map_err(str::to_owned)?; + let candidates: Vec<_> = scene + .world + .line_candidates(xyz(line.start), xyz(line.end), line.radius) + .map(|(index, _)| index) + .collect(); + let mut nearest = f32::MAX; + let mut result = None; + for (bit, pool) in [ + (1, QueryPool::Ground), + (2, QueryPool::Island), + (4, QueryPool::Conditional), + ] { + if line.source_pool_mask & bit == 0 { + continue; + } + if pool == QueryPool::Conditional && metadata.island_flags != 3 { + continue; + } + for &mesh_index in &meshes { + let mesh = &metadata.meshes[mesh_index]; + if mesh.pool != pool + || mesh.rejection_flags & line.reject_flags != 0 + || !(line.group == -1 + || mesh.matching_group == -1 + || line.group == mesh.matching_group) + { + continue; + } + let first = candidates.partition_point(|&i| i < mesh.triangle_range.start); + let last = candidates.partition_point(|&i| i < mesh.triangle_range.end); + for &index in &candidates[first..last] { + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.; 3], + }; + let vertices = scene.world.triangles()[index].triangle.vertices; + if !triangle_segment( + &mut hit, + xyz(line.start), + xyz(delta), + vertices, + line.radius, + 0.0, + ) { + continue; + } + let lower = if -hit.fraction >= 0.0 { + 0.0 + } else { + hit.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if fraction < nearest { + nearest = fraction; + result = Some(Hit { + fraction, + assembly: scene.registry.assembly(mesh.geometry), + }); + } + } + } + } + Ok(result) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/offboard/ground_geometry.rs b/crates/skate-game/src/physics/offboard/ground_geometry.rs new file mode 100644 index 00000000..9ca9e749 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_geometry.rs @@ -0,0 +1,80 @@ +//! Ground Sync submits edge geometry; the next PreUpdate consumes its result. +use super::ground_sync::SceneService; +use skate_core::{ + math::Vector3, + physics::board_world::BoardWorld, + player::offboard::ground_query::{ + self as query, ConsumeInput, Frame, GroundAdjustment, GroundQueryPacket, GroundQueryScene, + LineHit, QueryContext, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct State { + pending: Option<(GroundQueryPacket, [Option; 7])>, + collision_offset: f32, +} +impl State { + pub(crate) fn load(data: &Collections) -> Result { + Ok(Self { + pending: None, + //82C209E4 and Skate2 GetGeometryCollisionOffset82CFABD8: + //physics_grinds layout636, full keyCBFEFFEA12F4CC27. + collision_offset: data.float("physics_grinds", "default", "DeckCenterToTruck")?, + }) + } + pub(crate) fn reset(&mut self) { + self.pending = None; + } + pub(crate) fn consume(&mut self, input: ConsumeInput) -> GroundAdjustment { + let geometry = self + .pending + .take() + .map(|(packet, hits)| query::interpret_hits(&packet, hits)); + query::consume_geometry(input, geometry) + } + pub(crate) fn submit( + &mut self, + world: &BoardWorld, + frame: [[f32; 4]; 4], + velocity: [f32; 4], + context: QueryContext, + flags_2488: u32, + ) -> Result<(), String> { + let frame = query_frame(frame); + let search = query::edge_search(frame, context, xyz(velocity), flags_2488); + let candidates = SceneService { world }.edge_candidates(&search)?; + if let Some(packet) = query::select_edge(search, &candidates) + .and_then(|edge| query::prepare_packet(frame, context, edge, self.collision_offset)) + { + //82C20BF0 starts the real batch during Sync. Host execution may be + //synchronous, but interpretation/publication waits for PreUpdate. + let hits = SceneService { world }.query_lines(&packet)?; + self.pending = Some((packet, hits)); + } + //82D321CC: no selected edge means no submit, not a fabricated query. + Ok(()) + } +} +pub(crate) fn query_frame(frame: [[f32; 4]; 4]) -> Frame { + Frame { + right: xyz(frame[0]), + up: xyz(frame[1]), + forward: xyz(frame[2]), + position: xyz(frame[3]), + } +} +pub(crate) fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +pub(crate) fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.] +} +pub(crate) fn native_frame(frame: Frame) -> [[f32; 4]; 4] { + [ + lanes(frame.right), + lanes(frame.up), + lanes(frame.forward), + lanes(frame.position), + ] +} diff --git a/crates/skate-game/src/physics/offboard/ground_query.rs b/crates/skate-game/src/physics/offboard/ground_query.rs index 74038c2d..5fb9f205 100644 --- a/crates/skate-game/src/physics/offboard/ground_query.rs +++ b/crates/skate-game/src/physics/offboard/ground_query.rs @@ -4,7 +4,6 @@ mod edges; mod lines; mod transform; mod world; -pub use world::with_world_scene; use skate_core::{ math::Vector3, physics::board_world::WorldTriangle, @@ -12,6 +11,7 @@ use skate_core::{ Edge, EdgeSearch, Frame, GroundQueryPacket, GroundQueryScene, LineHit, }, }; +pub use world::with_world_scene; #[derive(Clone, Copy, Debug)] pub struct Bounds { pub min: Vector3, @@ -80,4 +80,3 @@ impl GroundQueryScene for Scene<'_> { #[cfg(test)] mod tests; - diff --git a/crates/skate-game/src/physics/offboard/ground_query/lines.rs b/crates/skate-game/src/physics/offboard/ground_query/lines.rs index d1b7478c..52ac58bb 100644 --- a/crates/skate-game/src/physics/offboard/ground_query/lines.rs +++ b/crates/skate-game/src/physics/offboard/ground_query/lines.rs @@ -1,5 +1,5 @@ use super::{ - Bounds, Scene, + Bounds, Mesh, Scene, transform::{self, sub}, }; use skate_core::{ @@ -20,6 +20,13 @@ pub(super) fn query( &[] }, ]; + query_pools(pools, packet) +} + +pub(super) fn query_pools( + pools: [&[Mesh<'_>]; 3], + packet: &GroundQueryPacket, +) -> Result<[Option; 7], &'static str> { for pool in pools { for mesh in pool { if mesh.triangles.len() != mesh.surfaces.len() { diff --git a/crates/skate-game/src/physics/offboard/ground_query/tests.rs b/crates/skate-game/src/physics/offboard/ground_query/tests.rs index 143bb48d..45aeaf27 100644 --- a/crates/skate-game/src/physics/offboard/ground_query/tests.rs +++ b/crates/skate-game/src/physics/offboard/ground_query/tests.rs @@ -86,47 +86,202 @@ fn decoded_face_uses_world_vertices_and_inclusive_bounds() { assert!(!transform::matches(2, 3)); } -fn triangle(y:f32)->WorldTriangle { +fn triangle(y: f32) -> WorldTriangle { use skate_core::physics::contact::RetailContactMaterial; - WorldTriangle::from_vertices([Vector3::new(-2.,y,-2.),Vector3::new(-2.,y,2.),Vector3::new(2.,y,-2.)], - RetailContactMaterial {static_friction:0.,dynamic_friction:0.,restitution:0.},123,0,[0.;3],0.).unwrap() + WorldTriangle::from_vertices( + [ + Vector3::new(-2., y, -2.), + Vector3::new(-2., y, 2.), + Vector3::new(2., y, -2.), + ], + RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 0., + }, + 123, + 0, + [0.; 3], + 0., + ) + .unwrap() } -fn packet()->GroundQueryPacket { +fn packet() -> GroundQueryPacket { use skate_core::player::offboard::ground_query::Line; - GroundQueryPacket {context:QueryContext {selection_flags_2948:0,matching_id_2952:7},center:Vector3::ZERO, - up:Vector3::new(0.,1.,0.),tangent:Vector3::new(0.,0.,1.), - lines:[Line {start:Vector3::new(-0.5,2.,-0.5),end:Vector3::new(-0.5,-2.,-0.5),radius:0.};7]} + GroundQueryPacket { + context: QueryContext { + selection_flags_2948: 0, + matching_id_2952: 7, + }, + center: Vector3::ZERO, + up: Vector3::new(0., 1., 0.), + tangent: Vector3::new(0., 0., 1.), + lines: [Line { + start: Vector3::new(-0.5, 2., -0.5), + end: Vector3::new(-0.5, -2., -0.5), + radius: 0., + }; 7], + } } -fn mesh<'a>(triangles:&'a[WorldTriangle],surfaces:&'a[u16],matching_group:i32)->Mesh<'a> { - Mesh {triangles,surfaces,matching_group,local_to_world:Frame::IDENTITY,world_to_local:Frame::IDENTITY, - local_bounds:Bounds {min:Vector3::new(-2.,-2.,-2.),max:Vector3::new(2.,2.,2.)}} +fn mesh<'a>(triangles: &'a [WorldTriangle], surfaces: &'a [u16], matching_group: i32) -> Mesh<'a> { + Mesh { + triangles, + surfaces, + matching_group, + local_to_world: Frame::IDENTITY, + world_to_local: Frame::IDENTITY, + local_bounds: Bounds { + min: Vector3::new(-2., -2., -2.), + max: Vector3::new(2., 2., 2.), + }, + } } #[test] fn actual_triangle_queries_preserve_pool_ties_group_filter_and_conditional_gate() { - let triangles=[triangle(0.)];let upper=[triangle(1.)]; - let ground=[mesh(&triangles,&[11],7)];let island=[mesh(&triangles,&[22],-1)]; - let conditional=[mesh(&upper,&[33],7)]; - let mut scene=Scene {ground_pool:&ground,island_pool:&island,conditional_pool:&conditional,island_flags:0, - static_edges:&[],primary_edges:PrimaryEdges::Normal {dynamic:&[],vehicles:&[]},indexed_edges:&[]}; - let p=packet();let hit=scene.query_lines(&p).unwrap()[0].unwrap(); - assert_eq!(hit.packed_surface,11);assert_eq!(hit.fraction,0.5);assert!(hit.face_normal.y>0.99); - let mut different=p;different.context.matching_id_2952=8; - assert_eq!(scene.query_lines(&different).unwrap()[0].unwrap().packed_surface,22); - scene.island_flags=3; - assert_eq!(scene.query_lines(&p).unwrap()[0].unwrap().packed_surface,33); + let triangles = [triangle(0.)]; + let upper = [triangle(1.)]; + let ground = [mesh(&triangles, &[11], 7)]; + let island = [mesh(&triangles, &[22], -1)]; + let conditional = [mesh(&upper, &[33], 7)]; + let mut scene = Scene { + ground_pool: &ground, + island_pool: &island, + conditional_pool: &conditional, + island_flags: 0, + static_edges: &[], + primary_edges: PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + indexed_edges: &[], + }; + let p = packet(); + let hit = scene.query_lines(&p).unwrap()[0].unwrap(); + assert_eq!(hit.packed_surface, 11); + assert_eq!(hit.fraction, 0.5); + assert!(hit.face_normal.y > 0.99); + let mut different = p; + different.context.matching_id_2952 = 8; + assert_eq!( + scene.query_lines(&different).unwrap()[0] + .unwrap() + .packed_surface, + 22 + ); + scene.island_flags = 3; + assert_eq!( + scene.query_lines(&p).unwrap()[0].unwrap().packed_surface, + 33 + ); } #[test] fn canonical_world_bridge_requires_metadata_and_queries_authored_surfaces() { - use skate_core::physics::{board_world::{BoardWorld,query_metadata::{Bounds as WorldBounds,QueryMesh,QueryMetadata,QueryPool}},drive_frames::RetailAffineTransform}; - let unannotated=BoardWorld::new(vec![triangle(0.)]); - let empty=||PrimaryEdges::Normal {dynamic:&[],vehicles:&[]}; - assert!(with_world_scene(&unannotated,empty(),&[],|scene|scene.query_lines(&packet())).is_err()); - let world=BoardWorld::with_query_metadata(vec![triangle(0.)],QueryMetadata { - packed_surfaces:vec![0x400],island_flags:0,static_edges:vec![],meshes:vec![QueryMesh { - triangle_range:0..1,local_to_world:RetailAffineTransform::IDENTITY,world_to_local:RetailAffineTransform::IDENTITY, - local_bounds:WorldBounds {min:Vector3::new(-2.,0.,-2.),max:Vector3::new(2.,0.,2.)},matching_group:7,pool:QueryPool::Ground, - }], - }).unwrap(); - let hits=with_world_scene(&world,empty(),&[],|scene|scene.query_lines(&packet())).unwrap(); - assert_eq!(hits[0].unwrap().packed_surface,0x400); + use skate_core::physics::{ + board_world::{ + BoardWorld, + query_metadata::{Bounds as WorldBounds, QueryMesh, QueryMetadata, QueryPool}, + }, + drive_frames::RetailAffineTransform, + }; + let unannotated = BoardWorld::new(vec![triangle(0.)]); + let empty = || PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }; + assert!( + with_world_scene(&unannotated, empty(), &[], |scene| scene + .query_lines(&packet())) + .is_err() + ); + let world = BoardWorld::with_query_metadata( + vec![triangle(0.)], + QueryMetadata { + packed_surfaces: vec![0x400], + island_flags: 0, + static_edges: vec![], + meshes: vec![QueryMesh { + triangle_range: 0..1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: WorldBounds { + min: Vector3::new(-2., 0., -2.), + max: Vector3::new(2., 0., 2.), + }, + matching_group: 7, + pool: QueryPool::Ground, + rejection_flags: 0, + geometry: 73, + }], + }, + ) + .unwrap(); + let hits = + with_world_scene(&world, empty(), &[], |scene| scene.query_lines(&packet())).unwrap(); + assert_eq!(hits[0].unwrap().packed_surface, 0x400); +} + +#[test] +fn indexed_world_bridge_retains_group_pool_and_conditional_selection() { + use skate_core::physics::{ + board_world::{ + BoardWorld, + query_metadata::{Bounds as WorldBounds, QueryMesh, QueryMetadata, QueryPool}, + }, + drive_frames::RetailAffineTransform, + }; + let mut triangles = Vec::new(); + let mut meshes = Vec::new(); + for i in 0..256 { + let original = triangle(if i == 102 { 1. } else { 0. }); + let shift = if (100..=102).contains(&i) { + 0. + } else { + 1000. + i as f32 * 10. + }; + let vertices = original + .triangle + .vertices + .map(|v| Vector3::new(v.x + shift, v.y, v.z)); + triangles.push( + WorldTriangle::from_vertices(vertices, original.material, 123, 0, [0.; 3], 0.).unwrap(), + ); + meshes.push(QueryMesh { + triangle_range: i..i + 1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: WorldBounds::from_points(vertices).unwrap(), + matching_group: if i == 100 { 8 } else { -1 }, + rejection_flags: 0, + geometry: i as u32, + pool: match i { + 101 => QueryPool::Island, + 102 => QueryPool::Conditional, + _ => QueryPool::Ground, + }, + }); + } + let mut metadata = QueryMetadata { + packed_surfaces: (0..256).collect(), + meshes, + static_edges: vec![], + island_flags: 0, + }; + for (flags, expected) in [(0, 101), (3, 102)] { + metadata.island_flags = flags; + let world = BoardWorld::with_query_metadata(triangles.clone(), metadata.clone()).unwrap(); + let hits = with_world_scene( + &world, + PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + &[], + |scene| scene.query_lines(&packet()), + ) + .unwrap(); + assert!( + hits.iter() + .all(|hit| hit.unwrap().packed_surface == expected) + ); + } } diff --git a/crates/skate-game/src/physics/offboard/ground_query/world.rs b/crates/skate-game/src/physics/offboard/ground_query/world.rs index bf07042e..1124ab70 100644 --- a/crates/skate-game/src/physics/offboard/ground_query/world.rs +++ b/crates/skate-game/src/physics/offboard/ground_query/world.rs @@ -1,36 +1,126 @@ //! Ephemeral adapter over the canonical BoardWorld metadata. Geometry is borrowed. -use super::{Bounds,Frame,Mesh,PrimaryEdges,Scene,Segment,IndexedEdgeBody}; -use skate_core::{math::Vector3,physics::{board_world::{BoardWorld,query_metadata::QueryPool},drive_frames::RetailAffineTransform},player::offboard::ground_query::Edge}; +use super::{Bounds, Frame, IndexedEdgeBody, Mesh, PrimaryEdges, Scene, Segment}; +use skate_core::player::offboard::ground_query::{ + EdgeSearch, GroundQueryPacket, GroundQueryScene, LineHit, +}; +use skate_core::{ + math::Vector3, + physics::{ + board_world::{BoardWorld, query_metadata::QueryPool}, + drive_frames::RetailAffineTransform, + }, + player::offboard::ground_query::Edge, +}; + +/// Static mesh candidates use the same ZIP hierarchy as the other world queries. +/// The existing ground leaf still owns transforms, exact bounds gates and hits. +pub struct WorldScene<'a> { + world: &'a BoardWorld, + edges: Scene<'a>, +} +impl GroundQueryScene for WorldScene<'_> { + type Error = &'static str; + fn edge_candidates(&mut self, search: &EdgeSearch) -> Result, Self::Error> { + self.edges.edge_candidates(search) + } + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error> { + let metadata = self.world.query_metadata()?; + let triangles = self.world.triangles(); + let mut candidates = Vec::new(); + for line in &packet.lines { + if !line.radius.is_finite() || line.radius < 0. { + return Err("Invalid Biped line radius"); + } + candidates.extend( + self.world + .candidate_mesh_indices(self.world.line_candidate_bounds( + line.start, + line.end, + line.radius, + ))?, + ); + } + candidates.sort_unstable(); + candidates.dedup(); + let mut pools: [Vec>; 3] = std::array::from_fn(|_| Vec::new()); + for index in candidates { + let source = &metadata.meshes[index]; + let pool = match source.pool { + QueryPool::Ground => 0, + QueryPool::Island => 1, + QueryPool::Conditional => 2, + }; + pools[pool].push(Mesh { + local_to_world: frame(source.local_to_world), + world_to_local: frame(source.world_to_local), + local_bounds: Bounds { + min: source.local_bounds.min, + max: source.local_bounds.max, + }, + matching_group: source.matching_group, + triangles: &triangles[source.triangle_range.clone()], + surfaces: &metadata.packed_surfaces[source.triangle_range.clone()], + }); + } + super::lines::query_pools( + [ + &pools[0], + &pools[1], + if metadata.island_flags == 3 { + &pools[2] + } else { + &[] + }, + ], + packet, + ) + } +} /// Caller supplies actual dynamic-provider views from the same live scene owner. /// Empty providers mean the authored scene actually has none, never a failed lookup. pub fn with_world_scene( - world:&BoardWorld, - primary_edges:PrimaryEdges<'_>, - indexed_edges:&[IndexedEdgeBody<'_>], - operation:impl FnOnce(&mut Scene<'_>)->Result, -)->Result { - let metadata=world.query_metadata()?; - let triangles=world.triangles(); - let mut pools:[Vec>;3]=std::array::from_fn(|_|Vec::new()); - for source in &metadata.meshes { - let index=match source.pool {QueryPool::Ground=>0,QueryPool::Island=>1,QueryPool::Conditional=>2}; - pools[index].push(Mesh { - local_to_world:frame(source.local_to_world),world_to_local:frame(source.world_to_local), - local_bounds:Bounds {min:source.local_bounds.min,max:source.local_bounds.max}, - matching_group:source.matching_group, - triangles:&triangles[source.triangle_range.clone()], - surfaces:&metadata.packed_surfaces[source.triangle_range.clone()], - }); - } - let edges:Vec<_>=metadata.static_edges.iter().map(|s|Segment { - edge:Edge {start:s.start,end:s.end},local_bounds:Bounds {min:s.local_bounds.min,max:s.local_bounds.max}, - }).collect(); - let mut scene=Scene {ground_pool:&pools[0],island_pool:&pools[1],conditional_pool:&pools[2], - island_flags:metadata.island_flags,static_edges:&edges,primary_edges,indexed_edges}; + world: &BoardWorld, + primary_edges: PrimaryEdges<'_>, + indexed_edges: &[IndexedEdgeBody<'_>], + operation: impl FnOnce(&mut WorldScene<'_>) -> Result, +) -> Result { + let metadata = world.query_metadata()?; + let edges: Vec<_> = metadata + .static_edges + .iter() + .map(|s| Segment { + edge: Edge { + start: s.start, + end: s.end, + }, + local_bounds: Bounds { + min: s.local_bounds.min, + max: s.local_bounds.max, + }, + }) + .collect(); + let edges = Scene { + ground_pool: &[], + island_pool: &[], + conditional_pool: &[], + island_flags: metadata.island_flags, + static_edges: &edges, + primary_edges, + indexed_edges, + }; + let mut scene = WorldScene { world, edges }; operation(&mut scene) } -fn frame(f:RetailAffineTransform)->Frame { - let v=|a:[f32;3]|Vector3::new(a[0],a[1],a[2]); - Frame {right:v(f.basis.columns[0]),up:v(f.basis.columns[1]),forward:v(f.basis.columns[2]),position:f.translation} +fn frame(f: RetailAffineTransform) -> Frame { + let v = |a: [f32; 3]| Vector3::new(a[0], a[1], a[2]); + Frame { + right: v(f.basis.columns[0]), + up: v(f.basis.columns[1]), + forward: v(f.basis.columns[2]), + position: f.translation, + } } diff --git a/crates/skate-game/src/physics/offboard/ground_sync.rs b/crates/skate-game/src/physics/offboard/ground_sync.rs new file mode 100644 index 00000000..01de535b --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_sync.rs @@ -0,0 +1,52 @@ +//! Host side scene service for the recovered BipedGround synchronizer. +//! +//! The synchronizer owns the query ordering in `skate-core`; this adapter only +//! exposes the already loaded BoardWorld. It does not cache contacts or run a +//! second solver. Every query therefore sees the same authored triangles and +//! packed surfaces as the board collision pass. +use super::ground_query::{self, IndexedEdgeBody, PrimaryEdges}; +use skate_core::{ + physics::board_world::BoardWorld, + player::offboard::ground_query::{EdgeSearch, GroundQueryPacket, GroundQueryScene, LineHit}, +}; + +pub(crate) struct SceneService<'a> { + pub world: &'a BoardWorld, +} + +impl GroundQueryScene for SceneService<'_> { + type Error = &'static str; + + fn edge_candidates( + &mut self, + search: &EdgeSearch, + ) -> Result, Self::Error> { + // Dynamic providers are host owned and are deliberately empty in the + // current single-player level. Static authored edges remain enumerated + // by with_world_scene from the world's query metadata. + ground_query::with_world_scene( + self.world, + PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + &[] as &[IndexedEdgeBody<'_>], + |scene| scene.edge_candidates(search), + ) + } + + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error> { + ground_query::with_world_scene( + self.world, + PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + &[] as &[IndexedEdgeBody<'_>], + |scene| scene.query_lines(packet), + ) + } +} diff --git a/crates/skate-game/src/physics/offboard/landing_deck.rs b/crates/skate-game/src/physics/offboard/landing_deck.rs new file mode 100644 index 00000000..69ec0bf4 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/landing_deck.rs @@ -0,0 +1,119 @@ +//! Offboard host for original LandingOnDeckManager82D78B28..82D79E30. +//! One retained manager and real scene-query completion; no physical-state facade. +mod input; +mod output; +mod query; +use super::contact_toolkit::StaticScene; +use crate::physics::{animated_skeleton::AnimatedSkeleton, player_input::PlayerInputRuntime}; +pub(crate) use output::PublishTargets; +use skate_core::player::offboard::landing_deck::{ + self as native, AssistInput, IkInput, Manager, QueryResult, Settings, UpdateOutput, Vector, +}; +use skate_data::collections::Collections; + +pub(crate) struct Owner { + ///Air and transitions must mutate this same manager, including Reset/force. + pub manager: Manager, + pub settings: Settings, + ///Some is an actually executed query, including an actual miss. + ///Retained until the ordered PostPhysics phase, never a fabricated seed. + completion: Option, +} +impl Owner { + pub(crate) fn load(data: &Collections) -> Result { + Ok(Self { + manager: Manager::default(), + settings: Settings { + deck_min_uprightness: data.float( + "physics_landingondeck", + "default", + "DeckMinUprightness", + )?, + approximate_com_height: data.float( + "physics_landingondeck", + "default", + "ApproxCOMHeightOnLanding", + )?, + }, + completion: None, + }) + } + + ///82D78B28 deliberately retains proposed trajectory AND pending completion. + pub(crate) fn reset(&mut self) { + self.manager.reset(); + } + + ///82D78D30. Query failure propagates without committing the speculative state. + pub(crate) fn assist( + &mut self, + scene: &StaticScene<'_>, + input: &PlayerInputRuntime, + maximum_velocity_change: f32, + ) -> Result<(), String> { + let mut next = self.manager; + let request = next.assist( + &AssistInput { + processed: input::processed(input)?, + position: input::position(input), + velocity: input::velocity(input), + maximum_velocity_change, + }, + &self.settings, + ); + let completion = request + .map(|request| query::execute(scene, request, &input.processed)) + .transpose()?; + if let Some(completion) = completion { + //Native Assist starts a new batch even if an older one was pending. + self.completion = Some(completion); + next.query_submitted(); + } + self.manager = next; + Ok(()) + } + + ///82D794A0: returns EVERY numeric output. A returned query has already run; + ///the caller must not submit UpdateOutput.query a second time. + pub(crate) fn update( + &mut self, + scene: &StaticScene<'_>, + input: &PlayerInputRuntime, + ) -> Result { + let mut next = self.manager; + let output = next.update(&input::processed(input)?, &self.settings); + let completion = output + .query + .map(|request| query::execute(scene, request, &input.processed)) + .transpose()?; + if let Some(completion) = completion { + self.completion = Some(completion); + next.query_submitted(); + } + self.manager = next; + Ok(output) + } + + ///82D792D0. mapped_position_12608 is the PRE-adjustment mapped board + ///translation copied by82BD8CB0..8D14, not animation_board after offsets. + ///The shared pose producer supplies that retained value explicitly. + pub(crate) fn calculate_accurate_ik_offset( + &mut self, + input: &PlayerInputRuntime, + animation: &AnimatedSkeleton, + mapped_position_12608: Vector, + time_to_land: f32, + ) -> Result { + Ok(self.manager.calculate_accurate_ik_offset(&IkInput { + processed: input::processed(input)?, + time_to_land, + animation_root: animation.roots.animation_to_world, + animation_com_10960: animation.record.centre_of_mass, + mapped_position_12608, + })) + } + + pub(crate) fn fill(&self) -> native::FillOutput { + self.manager.fill() + } +} diff --git a/crates/skate-game/src/physics/offboard/landing_deck/input.rs b/crates/skate-game/src/physics/offboard/landing_deck/input.rs new file mode 100644 index 00000000..14f11df9 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/landing_deck/input.rs @@ -0,0 +1,27 @@ +//! Actual completed PlayerInput/BoardToolkit fields read by82D78xxx. +use crate::physics::player_input::PlayerInputRuntime; +use skate_core::player::offboard::landing_deck::{Input, Vector}; + +pub(super) fn processed(input: &PlayerInputRuntime) -> Result { + let p = &input.processed; + let board = input + .toolkit + .as_ref() + .ok_or("Landing manager requires the current processed BoardToolkit")?; + Ok(Input { + board_up_80: board.deck[1], + board_position_112: board.deck[3], + board_velocity_400: p.vectors_400_416[0].map(f32::from_bits), + up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + support_1776: p.external_physics_1616.flags as i32, + flags_2480: p.flags_2480, + mode_2540: p.surface_mode_2540, + wheel_contacts_2556: p.wheel_count_2556, + }) +} +pub(super) fn position(input: &PlayerInputRuntime) -> Vector { + input.processed.vectors_544_560_592_608[2].map(f32::from_bits) +} +pub(super) fn velocity(input: &PlayerInputRuntime) -> Vector { + input.processed.vectors_544_560_592_608[3].map(f32::from_bits) +} diff --git a/crates/skate-game/src/physics/offboard/landing_deck/output.rs b/crates/skate-game/src/physics/offboard/landing_deck/output.rs new file mode 100644 index 00000000..b367050a --- /dev/null +++ b/crates/skate-game/src/physics/offboard/landing_deck/output.rs @@ -0,0 +1,23 @@ +//! Complete82D79420 writes into caller-owned physical output fields. +use super::Owner; +use skate_core::player::input_phase::OffBoardOutputFields; + +///Borrow actual shared output storage; this type retains no parallel state. +///The parent must supply native skeleton+48/+3482 destinations, not temporaries. +pub(crate) struct PublishTargets<'a> { + pub off_board: &'a mut OffBoardOutputFields, + pub skeleton_position_48: &'a mut [u32; 4], + pub skeleton_flag_3482: &'a mut u8, +} +impl Owner { + pub(crate) fn publish(&self, output: PublishTargets<'_>) { + let values = self.manager.fill(); + output.off_board.flag_316 = u8::from(values.can_land_316); + output.off_board.hippy_hurdling_317 = u8::from(values.hippy_hurdling_317); + if let Some(position) = values.moving_contact { + *output.skeleton_flag_3482 = 1; + *output.skeleton_position_48 = position.map(f32::to_bits); + } + //Native261 false leaves48 AND3482 untouched. + } +} diff --git a/crates/skate-game/src/physics/offboard/landing_deck/query.rs b/crates/skate-game/src/physics/offboard/landing_deck/query.rs new file mode 100644 index 00000000..02b0bb69 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/landing_deck/query.rs @@ -0,0 +1,78 @@ +//! Real accelerated trajectory query and ordered completion of82D79948/79B40. +use super::{Owner, input}; +use crate::physics::{offboard::contact_toolkit::StaticScene, player_input::PlayerInputRuntime}; +use skate_core::player::{ + input_phase::ProcessedPhysicsInput, + offboard::{ + hippy_jump, + landing_deck::{QueryRequest, QueryResult, SyncInput}, + }, +}; + +pub(super) fn execute( + scene: &StaticScene<'_>, + request: QueryRequest, + processed: &ProcessedPhysicsInput, +) -> Result { + //79948 stack54=processed2952; stack5C=0; stack64=null. + //8276E280 writes request80/84 from stack54/5C.8276DBE0 forwards + //these to82770B40's matching-group and mesh-rejection checks. + //Use the accelerated arc provider, NOT Ground's sweep-oriented Scene batch. + scene + .trajectory(request, processed.actor_query_2952 as i32, 0) + .map_err(str::to_owned) +} + +impl Owner { + ///Run the actual query before acknowledging native pending262. + ///The returned real result remains private until PostPhysics. + pub(crate) fn submit( + &mut self, + scene: &StaticScene<'_>, + request: QueryRequest, + processed: &ProcessedPhysicsInput, + ) -> Result<(), String> { + let completion = execute(scene, request, processed)?; + self.completion = Some(completion); + self.manager.query_submitted(); + Ok(()) + } + + ///Native caller tests262 before Sync. Host completes work synchronously at + ///submission but preserves its consumption phase; this is not a claim of + ///the console job backend's timing or allocation semantics. + pub(crate) fn post_physics(&mut self, input: &PlayerInputRuntime) -> Result<(), String> { + if !self.manager.pending_262 { + if self.completion.is_some() { + return Err("Landing query completion exists without native pending262".into()); + } + return Ok(()); + } + let completion = self + .completion + .ok_or("Landing pending262 has no submitted world-query completion")?; + let p = &input.processed; + self.manager.sync( + completion.valid().then_some(completion), + &SyncInput { + position_592: input::position(input), + flags_2488: p.flags_2488, + }, + |_| { + //Only the original79E30 gate inside Sync invokes this existing + //numeric provider. No state503, custom branch, or synthetic result. + let processed = input::processed(input)?; + Ok::<_, String>(hippy_jump::calculate(hippy_jump::Input { + desired_height: f32::from_bits(p.vectors_880_896_912_928_944[3][1]) + + f32::from_bits(0x3e4c_cccd), + board_position: processed.board_position_112, + centre_of_mass_position: input::position(input), + reference_up: processed.up_544, + current_velocity: processed.board_velocity_400, + })) + }, + )?; + self.completion = None; + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/offboard/mod.rs b/crates/skate-game/src/physics/offboard/mod.rs index 212021df..7a12fa1b 100644 --- a/crates/skate-game/src/physics/offboard/mod.rs +++ b/crates/skate-game/src/physics/offboard/mod.rs @@ -1,17 +1,22 @@ //! Off-board game adapters over the shared physical and scene owners. -pub(crate) mod air_state; -pub(crate) mod air_diagnostics; -pub(crate) mod air_trajectory; -pub(crate) mod board_effects; -pub(crate) mod contact_queries; -pub(crate) mod feet; -pub(crate) mod ground_input; +//! +//! These modules are the source-backed implementation units for the off-board +//! owner. The coordinator calls them through the selected physical state. +#![allow(dead_code)] +pub(crate) mod air_feet; +pub(crate) mod air_selector; +pub(crate) mod board_manager; +pub(crate) mod board_possession; +pub(crate) mod contact_toolkit; +pub(crate) mod grab_scene; +pub(crate) mod ground_geometry; pub(crate) mod ground_query; -pub(crate) mod ground_state; -pub(crate) mod hand_drives; +pub(crate) mod ground_sync; +pub(crate) mod landing_deck; pub(crate) mod pose_adjust; -pub(crate) mod possession; -pub(crate) mod runtime; +pub(crate) mod post_physics; pub(crate) mod settings; pub(crate) mod skeleton_air; pub(crate) mod skeleton_ground; + +pub(crate) mod contact_queries; diff --git a/crates/skate-game/src/physics/offboard/post_physics.rs b/crates/skate-game/src/physics/offboard/post_physics.rs new file mode 100644 index 00000000..9b23b6d6 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/post_physics.rs @@ -0,0 +1,50 @@ +//! Offboard state slot40 at82DB6DCC, after Skeleton feedback82DB6DB8 and +//! before PostWipeoutCheck82DB6DF8 consumes the same request owner. +use crate::physics::{GamePhysics, SkaterRuntime, biped_air, biped_ground, wipeout}; +use skate_core::player::{offboard::ground_lifecycle::CollisionInput, state::PhysicalStateId}; + +pub(crate) fn advance(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let state = skater.player_state.current(); + if !matches!( + state, + PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::BipedAir + | PhysicalStateId::LandingOnDeck + ) { + return Ok(()); + } + //Borrow the already completed reports and pose errors. This produces no + //contacts, default support, replacement dynamics, or second request owner. + let frame = wipeout::Observations { + processed: &skater.player_input.processed, + board: &physics.riding.ground, + collision: &skater.collision_feedback, + deck: crate::physics::solve::deck_frame(&physics.board), + input_board: skater.animated_skeleton.board_frames.animation_target, + world_to_animation: skater.animated_skeleton.roots.world_to_animation, + pose_error: skater.collision_pose_error, + maximum_pose_error: skater.collision_maximum_error, + jump_fix_frames: skater.player_state.post.jump_fix_frames, + air: &skater.air_reckoning.state, + system_up_y: physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: skater.trajectory.selector.grind_locked_to_middle(), + grind_normal: skater.trajectory.selector.grind_normal(), + } + .frame()?; + match state { + PhysicalStateId::LandingOnDeck => crate::physics::landing_on_deck::post(skater, frame), + PhysicalStateId::BipedAir => biped_air::post(skater, frame), + PhysicalStateId::BipedGround | PhysicalStateId::OffBoardPushing => { + let collision = CollisionInput { + shared: &frame, + skeleton_velocity_16336: skater.skeleton_input.root_velocity, + contact_flag_4072: skater.collision_feedback.flags.group_8, + contact_force_4056: skater.collision_feedback.maximum_group_8_force, + }; + biped_ground::post(skater, collision); + Ok(()) + } + _ => unreachable!("offboard state checked before borrowing its observations"), + } +} diff --git a/crates/skate-game/src/physics/offboard/settings.rs b/crates/skate-game/src/physics/offboard/settings.rs index b5566db4..6806f9d4 100644 --- a/crates/skate-game/src/physics/offboard/settings.rs +++ b/crates/skate-game/src/physics/offboard/settings.rs @@ -2,21 +2,15 @@ mod board; mod curves; mod metrics; -mod possession; #[cfg(test)] mod tests; use skate_core::{ - player::offboard::{controller, movement_intent, movement_velocity}, + player::offboard::{air_launch, controller, movement_intent, movement_velocity}, point_graph::PointGraph, }; use skate_data::{animation_metadata::AnimationMetadata, collections::Collections}; pub(crate) struct Settings { - pub air_blend_curve: PointGraph<8>, - pub air_query: skate_core::player::offboard::air_queries::Settings, - pub jump_height: f32, - pub jump_speed_scalar: f32, - pub possession: skate_core::player::offboard::board_possession::lifecycle::Settings, pub controller: controller::Settings, pub board: skate_core::player::offboard::ground_sync::BoardSettings, pub metrics: [Option; 3], @@ -24,6 +18,7 @@ pub(crate) struct Settings { pub movement_vs_stick_angle: PointGraph<8>, ///82D310F8 full key2DD95B399BAE313E. pub turn_vs_stick_angle: PointGraph<8>, + pub air_launch: air_launch::Settings, } impl Settings { /// Share the same native ABIN metadata loaded for SkaterAnimation. Required @@ -32,32 +27,6 @@ impl Settings { let biped = |name| curves::load::<8>(data, "physics_biped", name); let (sprint_blend, bounds) = biped("Hash_6B93C51256A30FB4")?; Ok(Self { - air_blend_curve: curves::load::<8>( - data, - "physics_state_offboard_air", - "TrajBlendAmountVsTime", - )? - .0, - air_query: skate_core::player::offboard::air_queries::Settings { - height: data.float( - "physics_state_offboard_air", - "default", - "Hash_AB0D9EAEBFC584E9", - )?, - sphere_radius: data.float( - "physics_state_offboard_air", - "default", - "TrajectorySphereRadius", - )?, - start_index: data.integer( - "physics_state_offboard_air", - "default", - "TrajectoryStartIndex", - )?, - }, - jump_height: data.float("physics_biped", "default", "JumpHeight")?, - jump_speed_scalar: data.float("physics_biped", "default", "JumpSpeedScalar")?, - possession: possession::load(data)?, controller: controller::Settings { movement_intent: movement_intent::Settings { sprint_speed: curves::load::<4>( @@ -94,6 +63,28 @@ impl Settings { "TurnVsStickAngle", )? .0, + air_launch: air_launch::Settings { + jump_speed_scalar: data.float("physics_biped", "default", "JumpSpeedScalar")?, + jump_height: data.float("physics_biped", "default", "JumpHeight")?, + }, }) } + + pub(crate) fn into_controller_parts( + self, + ) -> ( + controller::Settings, + [Option; 3], + PointGraph<8>, + PointGraph<8>, + air_launch::Settings, + ) { + ( + self.controller, + self.metrics, + self.movement_vs_stick_angle, + self.turn_vs_stick_angle, + self.air_launch, + ) + } } diff --git a/crates/skate-game/src/physics/offboard/settings/tests.rs b/crates/skate-game/src/physics/offboard/settings/tests.rs index 7a9c2f9d..df156b07 100644 --- a/crates/skate-game/src/physics/offboard/settings/tests.rs +++ b/crates/skate-game/src/physics/offboard/settings/tests.rs @@ -85,7 +85,4 @@ fn actual_stock_settings_and_metrics_load_together() { }); assert!(speeds[0] > 0.0 && speeds[0] < speeds[1] && speeds[1] < speeds[2]); assert_eq!(settings.controller.movement_intent.sprint_time_cap, 4.0); - //Independent native lookup8 keys:893DA1B966231D76 /6CFF6F2C43F0AD41. - assert_eq!(settings.possession.hide_distance, 30.0); - assert_eq!(settings.possession.hide_offset, 1000.0); } diff --git a/crates/skate-game/src/physics/offboard/skeleton_air.rs b/crates/skate-game/src/physics/offboard/skeleton_air.rs index 4254df5c..6041aa0e 100644 --- a/crates/skate-game/src/physics/offboard/skeleton_air.rs +++ b/crates/skate-game/src/physics/offboard/skeleton_air.rs @@ -1,72 +1,81 @@ -//! Live Skeleton BipedAir82BDDD70 over the shared solve owners. -use crate::physics::{GamePhysics, SkaterRuntime, skeleton_input_runtime::SkeletonOwners}; -use skate_core::physics::skeleton_animation_record::{ - AnimationPartTransform as Frame, compose_affine, +//! BipedAir Skeleton82BDDD70 with the existing skeleton, drives and solver. +//! The caller supplies the shared reckoning service; no second physics owner. +use super::skeleton_ground::ReckoningUpdate; +use crate::physics::{ + skeleton_air::SkeletonAir, + skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}, }; -pub(crate) fn update( - physics: &mut GamePhysics, - skater: &mut SkaterRuntime, - frame: Frame, - trajectory_position: [f32; 4], - body_position: [f32; 4], - height: f32, -) -> Result<(), String> { - let collision = crate::physics::input_phase::collision(skater); - let p = &mut skater.player_input.processed; - let s = &mut skater.animated_skeleton; - s.board_frames.skate_root = compose_affine(&s.roots.animation_to_world, &s.record.pose[0]); - s.board_frames - .update_com_lift(&s.roots.animation_to_world, body_position, height); - let target = skate_core::physics::skeleton_biped_air::prepare( - &mut s.roots, - frame, - trajectory_position, - s.record.centre_of_mass, - &skater.skeleton_input.drive_frames[0], - p.flags_2476, - &mut p.flags_2468, - ); - s.board_frames.animation_target = target; - if p.flags_2480 & 0x8000 != 0 { - s.board_frames.physical_board = - skater - .skeleton_air - .apply_board(&mut physics.board, &target, true); +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + board_runtime::BoardRuntime, + rigid_body::RetailSimulationStep, + skeleton_animation_record::{AnimationPartTransform as Frame, compose_affine}, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +pub(crate) struct Input { + pub frame_208: Frame, + pub trajectory_position_272: [f32; 4], + pub body_target_416: [f32; 4], + pub lift_436: f32, + ///Actual processed board-forward96, NOT effective animation-forward224. + pub board_forward_96: [f32; 4], +} + +impl SkeletonInputRuntime { + ///Old-root skate/COM -> new animation root -> held-board target -> + ///GeneralUpdate -> shared Reckoning -> clear next trajectory12176. + ///Return the actual processed0..48 target for the parent's input owner. + pub(crate) fn update_biped_air( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + input: Input, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + finish_reckoning: F, + ) -> Result + where + F: FnOnce(ReckoningUpdate, &ProcessedPhysicsInput, f32) -> Result<(), String>, + { + let s = &mut owners.animated; + //82BDEE08 and82BDE310 use the OLD root, before82BDDDC8 changes it. + s.board_frames.skate_root = compose_affine(&s.roots.animation_to_world, &s.record.pose[0]); + s.board_frames.update_com_lift( + &s.roots.animation_to_world, + input.body_target_416, + input.lift_436, + ); + let target = skate_core::physics::skeleton_biped_air::prepare( + &mut s.roots, + input.frame_208, + input.trajectory_position_272, + s.record.centre_of_mass, + &self.drive_frames[0], + p.flags_2476, + &mut p.flags_2468, + ); + s.board_frames.animation_target = target; + if p.flags_2480 & 0x8000 != 0 { + s.board_frames.physical_board = air.apply_board(board, &target, true); + } + self.general_update(p, owners, globals, collision, simulation)?; + finish_reckoning( + ReckoningUpdate { + up: owners.animated.roots.animation_to_world[1], + forward: input.board_forward_96, + blend: 0.5, + }, + p, + owners.animation_input.extra.physical_body_spin, + )?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(target) } - let mut owners = SkeletonOwners { - animated: s, - body: &mut skater.skeleton, - drives: &mut skater.skeleton_drives, - ik: &mut skater.foot_ik, - animation_input: &mut skater.animation_input, - correction: &mut skater.skeleton_output.correction, - pose_errors: &mut skater.pose_errors, - }; - skater.skeleton_input.general_update( - p, - &mut owners, - &skater.animation.packet.hierarchy, - &collision, - physics.settings.step.simulation, - )?; - let root = owners.animated.roots.animation_to_world; - //82D8E3E0's forward is the processed board forward96 in this Air caller. - physics.riding.update_biped_reckoning( - &mut skater.air_reckoning.state, - super::skeleton_ground::ReckoningUpdate { - up: root[1], - forward: skater - .player_input - .toolkit - .as_ref() - .ok_or("BipedAir Skeleton has no board toolkit")? - .deck[2], - blend: 0.5, - }, - p.flags_2468, - owners.animation_input.extra.physical_body_spin, - ); - owners.animated.finish_ground(); - owners.animation_input.fields.flags2468 = p.flags_2468; - Ok(()) } diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground.rs b/crates/skate-game/src/physics/offboard/skeleton_ground.rs index 9c9452bf..f5914d7f 100644 --- a/crates/skate-game/src/physics/offboard/skeleton_ground.rs +++ b/crates/skate-game/src/physics/offboard/skeleton_ground.rs @@ -1,9 +1,14 @@ //! Original Biped Skeleton update82BDE060 with the existing physical owners. mod frames; +#[cfg(test)] +mod pose_audit; +mod state; use crate::physics::{ skeleton_air::SkeletonAir, skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}, }; +#[cfg(test)] +pub(crate) use pose_audit::audit_render_parts; use skate_core::{ animation::output::NativeMatrix, physics::{ @@ -12,8 +17,9 @@ use skate_core::{ }, player::input_phase::ProcessedPhysicsInput, }; +pub(crate) use state::State; ///Actual Sync input: caller's constructed frame and Biped state1056 COM. -///retained_board_16016 belongs to the canonical SkeletonBoardFrames owner. +///State retains animation-space16016 independently of world-space12496. pub(crate) struct Input<'a> { pub world_frame: &'a Transform, pub centre_of_mass_1056: [f32; 4], @@ -25,57 +31,13 @@ pub(crate) struct ReckoningUpdate { pub forward: [f32; 4], pub blend: f32, } - -///Borrow the same bodies, IK, roots, collision feedback and camera reckoning as -///the riding phases. Sync supplies the completed Biped job frame and COM. -pub(crate) fn update( - physics: &mut crate::physics::GamePhysics, - skater: &mut crate::physics::SkaterRuntime, - frame: Transform, - centre_of_mass: [f32; 4], -) -> Result<(), String> { - let collision = crate::physics::input_phase::collision(skater); - let flags = skater.player_input.processed.flags_2468; - let spin = skater.animation_input.extra.physical_body_spin; - let mut owners = SkeletonOwners { - animated: &mut skater.animated_skeleton, - body: &mut skater.skeleton, - drives: &mut skater.skeleton_drives, - ik: &mut skater.foot_ik, - animation_input: &mut skater.animation_input, - correction: &mut skater.skeleton_output.correction, - pose_errors: &mut skater.pose_errors, - }; - skater.skeleton_input.update_biped_ground( - &mut skater.skeleton_air, - &mut physics.board, - Input { - world_frame: &frame, - centre_of_mass_1056: centre_of_mass, - }, - &mut skater.player_input.processed, - &mut owners, - &skater.animation.packet.hierarchy, - &collision, - physics.settings.step.simulation, - |update| { - physics.riding.update_biped_reckoning( - &mut skater.air_reckoning.state, - update, - flags, - spin, - ); - Ok(()) - }, - )?; - Ok(()) -} impl SkeletonInputRuntime { pub(crate) fn update_biped_ground( &mut self, air: &mut SkeletonAir, board: &mut BoardRuntime, input: Input<'_>, + state: &mut State, p: &mut ProcessedPhysicsInput, owners: &mut SkeletonOwners<'_>, globals: &[NativeMatrix], @@ -87,27 +49,17 @@ impl SkeletonInputRuntime { F: FnOnce(ReckoningUpdate) -> Result<(), String>, { let s = &mut owners.animated; - //82BDEE08 uses animation record6464+16, not the current post-IK drive. - s.board_frames.skate_root = skate_core::physics::skeleton_animation_record::compose_affine( - &s.roots.animation_to_world, - &s.record.pose[0], - ); - //82BDE310 must read the OLD root basis, before82BDE100 replaces it. - s.board_frames.update_com_lift( - &s.roots.animation_to_world, - input.centre_of_mass_1056, - f32::from_bits(0x3e75_c28f), - ); - let target = frames::prepare( + let target = frames::prepare_pose( &mut s.roots, - input.world_frame, + &mut s.board_frames, + &s.record.pose[0], &self.drive_frames[0], - &mut s.board_frames.retained_board_16016, + &mut state.retained_board, + input, p.flags_2476, p.flags_2484, &mut p.flags_2468, ); - s.board_frames.animation_target = target; if p.flags_2480 & 0x8000 != 0 || p.flags_2484 & 1 != 0 { //Reuse the shared Ground/Air history and actual board anchor. s.board_frames.physical_board = air.apply_board(board, &target, true); diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs index 28ca8b5b..b07726ee 100644 --- a/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs @@ -1,7 +1,39 @@ use skate_core::physics::{ skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_board_frames::SkeletonBoardFrames, skeleton_root::SkeletonRootFrames, }; +pub(super) fn prepare_pose( + roots: &mut SkeletonRootFrames, + board_frames: &mut SkeletonBoardFrames, + animation_board: &Transform, + mapped: &Transform, + retained: &mut Transform, + input: super::Input<'_>, + flags_2476: u32, + flags_2484: u32, + flags_2468: &mut u32, +) -> Transform { + //82BDEE08 consumes animation record6464+16 using the OLD world root. + board_frames.skate_root = compose_affine(&roots.animation_to_world, animation_board); + //82BDE310 also uses the old basis, with world-space state1056 COM. + board_frames.update_com_lift( + &roots.animation_to_world, + input.centre_of_mass_1056, + f32::from_bits(0x3e75_c28f), + ); + let target = prepare( + roots, + input.world_frame, + mapped, + retained, + flags_2476, + flags_2484, + flags_2468, + ); + board_frames.animation_target = target; + target +} pub(super) fn prepare( roots: &mut SkeletonRootFrames, world: &Transform, diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs index 3323ee62..0f2256a5 100644 --- a/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs @@ -1,5 +1,44 @@ use super::*; use skate_core::physics::skeleton_animation_record::IDENTITY; + +#[test] +fn biped_ik_bindings_keep_adjacent_foot_volumes_distinct() { + use skate_core::animation::foot_ik::state::LIMBS; + //TU3 Init82BED74C..770:1120 target parts and1136 adjacent parts. + //These are physical part indices, not animation hierarchy indices. + assert_eq!(LIMBS.map(|limb| limb.part), [15, 19, 3, 7]); + assert_eq!( + LIMBS.map(|limb| limb.parent_part), + [Some(16), Some(20), None, None] + ); +} + +#[test] +fn disabling_feet_target_ik_fades_board_weight_without_disabling_the_pose() { + use skate_core::animation::foot_ik::status::{BlendSettings, LimbStatus, Mode, update_blends}; + let mut limbs = [LimbStatus::default(); 4]; + for foot in &mut limbs[..2] { + foot.mode = Mode::OnDeck; + foot.board_blend = 0.5; + } + //Synthetic exact-binary test step, not a replacement stock setting. + let settings = BlendSettings { + hand_inner_padding: [0.; 4], + hand_outer_padding: [1.; 4], + external_blend_step: 0.125, + board_blend_step: 0.25, + }; + //82BEEE58..88 selects target zero from2480bit27 and bounds its delta. + update_blends(&mut limbs, 1., 1., 0x0800_0000, &settings); + assert_eq!([limbs[0].board_blend, limbs[1].board_blend], [0.25; 2]); + update_blends(&mut limbs, 1., 1., 0x0800_0000, &settings); + update_blends(&mut limbs, 1., 1., 0x0800_0000, &settings); + assert_eq!([limbs[0].board_blend, limbs[1].board_blend], [0.; 2]); + assert_eq!([limbs[0].mode, limbs[1].mode], [Mode::OnDeck; 2]); + assert_eq!([limbs[2].mode, limbs[3].mode], [Mode::Disabled; 2]); + update_blends(&mut limbs, 1., 1., 0, &settings); + assert_eq!([limbs[0].board_blend, limbs[1].board_blend], [0.25; 2]); +} #[test] fn frozen_board_uses_retained_pose_and_flips_complete_axes() { let mut roots = SkeletonRootFrames::default(); @@ -19,3 +58,72 @@ fn frozen_board_uses_retained_pose_and_flips_complete_axes() { assert_eq!(flags, 0x80002); assert!(roots.initialize_heading); } + +#[test] +fn biped_com_and_skate_root_use_old_basis_without_overwriting_physical_board() { + let mut roots = SkeletonRootFrames::default(); + let old = [ + [0., 0., -1., 0.], + [0., 1., 0., 0.], + [1., 0., 0., 0.], + [10., 20., 30., 0.], + ]; + roots.reset_initial_alignment(old); + let mut frames = SkeletonBoardFrames::default(); + frames.physical_board[3] = [90., 80., 70., 0.]; + let physical = frames.physical_board; + let mut animation = IDENTITY; + animation[3] = [2., 3., 4., 0.]; + let mut mapped = IDENTITY; + mapped[3] = [5., 6., 7., 0.]; + let mut new_world = IDENTITY; + new_world[3] = [100., 200., 300., 0.]; + let com = [11., 22., 33., 0.]; + let mut retained = IDENTITY; + let mut flags = 0; + let target = prepare_pose( + &mut roots, + &mut frames, + &animation, + &mapped, + &mut retained, + super::super::Input { + world_frame: &new_world, + centre_of_mass_1056: com, + }, + 0, + 0, + &mut flags, + ); + assert_eq!(frames.skate_root[3], [14., 23., 28., 0.]); + assert_eq!(frames.com_frame[3], com); + for axis in 0..3 { + for lane in 0..3 { + assert!((frames.com_frame[axis][lane] - old[axis][lane]).abs() < 1e-5); + } + } + assert_eq!(roots.animation_to_world, new_world); + assert_eq!(retained, mapped); + assert_eq!(target[3], [105., 206., 307., 0.]); + assert_eq!(frames.physical_board, physical); + + //Holding2484bit0 preserves LOCAL16016 while a new root moves the target. + new_world[3][0] = 110.; + let target = prepare_pose( + &mut roots, + &mut frames, + &animation, + &IDENTITY, + &mut retained, + super::super::Input { + world_frame: &new_world, + centre_of_mass_1056: com, + }, + 0, + 1, + &mut flags, + ); + assert_eq!(retained, mapped); + assert_eq!(target[3], [115., 206., 307., 0.]); + assert_eq!(frames.physical_board, physical); +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/pose_audit.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/pose_audit.rs new file mode 100644 index 00000000..85302ff0 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/pose_audit.rs @@ -0,0 +1,42 @@ +//! Read-only fixture diagnostics; never modifies simulation or acceptance limits. +use crate::physics::SkaterRuntime; +use skate_core::physics::skeleton_animation_record::compose_affine; + +///Each tuple is(part, render-vs-record maximum XYZ matrix error, +///record-vs-live maximum XYZ matrix error). Include basis AND translation: +///a whole-body orientation failure can have small joint-position errors. +///Render matrices are animation-space globals; physical records are world-space +///volume frames. Undo the authored volume frame before comparing bone frames. +pub(crate) fn audit_render_parts(skater: &SkaterRuntime) -> [(usize, f32, f32); 24] { + let live = skater.skeleton.part_transforms(); + std::array::from_fn(|part| { + let expected = compose_affine( + &skater.skeleton.record.pose[part], + &skater.skeleton_output.pose.geometry.inverse_part_frames[part], + ); + let rendered = compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &skater.render_pose[skater.animated_skeleton.bone_indices[part]], + ); + let error = |a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]| { + let mut maximum = 0.0_f32; + for column in 0..4 { + for lane in 0..3 { + let delta = (a[column][lane] - b[column][lane]).abs(); + if !delta.is_finite() { + return f32::INFINITY; + } + maximum = maximum.max(delta); + } + } + maximum + }; + //Part0 intentionally records the real deck, not the skeleton proxy. + //Its second value is diagnostic only and must not be asserted zero. + ( + part, + error(&rendered, &expected), + error(&skater.skeleton.record.pose[part], &live[part]), + ) + }) +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/state.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/state.rs new file mode 100644 index 00000000..54f50e99 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/state.rs @@ -0,0 +1,74 @@ +//! Persistent animation-space board16016 and the Biped reckoning callback. +use super::{ReckoningUpdate, Transform}; +use crate::physics::{air_reckoning::AirReckoning, riding_outputs::RidingOutputs}; +use skate_core::{ + physics::skeleton_animation_record::IDENTITY, + player::{input_phase::ProcessedPhysicsInput, offboard::ground_reckoning}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +pub(crate) struct State { + //82BDE158..17C copies mapped12624 here only when2484bit0 is clear. + //This must never alias physical_board12496 or be reset on each update. + pub(super) retained_board: Transform, + ground_normal_smoothing: [f32; 4], + tilt_vs_rotation: PointGraph<8>, + tilt_vs_slope: PointGraph<8>, +} + +impl State { + pub(crate) fn load(data: &Collections) -> Result { + let graph = |field| -> Result, String> { + let words = data + .words::<16>("physics_reckoning", "default", field)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[..8].try_into().unwrap(), + y: words[8..].try_into().unwrap(), + }) + }; + Ok(Self { + //Skeleton ctor82BD7760..7884 initializes16016 to identity. + retained_board: IDENTITY, + ground_normal_smoothing: data + .words::<4>("physics_reckoning", "default", "GroundNormalSmoothing")? + .map(f32::from_bits), + tilt_vs_rotation: graph("TiltVsRotGround")?, + tilt_vs_slope: graph("TiltVsSlopeGround")?, + }) + } + + ///Apply after GeneralUpdate and trajectory clear, replacing the riding job. + ///82BDE2CC explicitly passes r7=1: consume physical body spin2812. + pub(crate) fn finish_reckoning( + &self, + update: ReckoningUpdate, + riding: &mut RidingOutputs, + air: &mut AirReckoning, + processed: &ProcessedPhysicsInput, + physical_body_spin: f32, + ) -> ground_reckoning::Output { + let previous = riding.reckoning.up; + ground_reckoning::update( + &mut riding.reckoning, + &mut riding.reckoning_frames, + &mut riding.body_spin, + &mut air.state, + ground_reckoning::Settings { + ground_normal_smoothing: self.ground_normal_smoothing, + tilt_vs_rotation: &self.tilt_vs_rotation, + tilt_vs_slope: &self.tilt_vs_slope, + }, + ground_reckoning::Input { + previous_up: [previous.x, previous.y, previous.z, 0.0], + requested_up: update.up, + requested_forward: update.forward, + blend: update.blend, + reverse_stance: processed.flags_2468 & 0x100000 != 0, + enable_body_spin_input: true, + physical_body_spin_2812: physical_body_spin, + }, + ) + } +} diff --git a/crates/skate-game/src/physics/offboard_audit_trace.rs b/crates/skate-game/src/physics/offboard_audit_trace.rs new file mode 100644 index 00000000..67b3fab9 --- /dev/null +++ b/crates/skate-game/src/physics/offboard_audit_trace.rs @@ -0,0 +1,90 @@ +//! TEMPORARY bottom-up audit observations; not native gameplay behavior. +//! Opt in with SKATE3_AUDIT_OFFBOARD_TICKS=start:end (inclusive simulation ticks). +//! Disabled by default. No gameplay state, guards, poses or forces are changed. +use super::{GamePhysics, PlayerControls, SkaterRuntime}; +use std::sync::OnceLock; + +static WINDOW: OnceLock> = OnceLock::new(); + +pub(super) fn stage( + tick: u64, + phase: &str, + physics: &GamePhysics, + s: &SkaterRuntime, + controls: &PlayerControls, +) { + let window = WINDOW.get_or_init(|| { + std::env::var_os("SKATE3_AUDIT_OFFBOARD_TICKS").map(|value| { + let value = value + .to_str() + .expect("Audit tick window must be UTF-8 start:end"); + let (start, end) = value + .split_once(':') + .expect("Audit tick window must be start:end"); + let start: u64 = start.parse().expect("Invalid audit start tick"); + let end: u64 = end.parse().expect("Invalid audit end tick"); + assert!(start <= end, "Audit tick window is reversed"); + (start, end) + }) + }); + let Some((start, end)) = *window else { + return; + }; + if tick < start || tick > end { + return; + } + let p = &s.player_input.processed; + let roots = &s.animated_skeleton.roots; + let owner = &s.biped_ground.controller.state; + let deck = physics.board.bodies()[skate_core::physics::board::BodyId::Deck.index()]; + eprintln!( + "OFFBOARD_AUDIT_BAIL tick={tick} phase={phase} state={:?} requests={:?} pose_error={:?} maximum_pose_error={:?} root_velocity={:?} part_errors={:?}", + s.player_state.current(), + s.wipeout.state, + s.collision_pose_error, + s.collision_maximum_error, + s.skeleton_input.root_velocity, + s.pose_errors.parts + ); + eprintln!( + "OFFBOARD_AUDIT tick={tick} phase={phase} state={:?} controls_tick={} controller_words={:08x?} ag={:?} mg={:?} flags={:08x?} root_bits={:08x?} inverse_root_bits={:08x?} motion={:?} output={:?} air_position={:?} air_velocity={:?} physical_com={:?} physical_velocity={:?} board_state={:?} hand={} hand_drives={:?} deck_drive={:?} deck_rates={:?} query_ready={} contact={:?}", + s.player_state.current(), + controls.ticks, + controls.controller.words(), + controls.action_intents, + s.animation.motion.animation.motion_intents, + [ + p.flags_2468, + p.flags_2472, + p.flags_2476, + p.flags_2480, + p.flags_2484 + ], + roots.animation_to_world.map(|v| v.map(f32::to_bits)), + roots.world_to_animation.map(|v| v.map(f32::to_bits)), + owner.motion, + owner.frame_output, + s.biped_air.state.result.position_272, + s.biped_air.state.result.velocity_288, + s.skeleton.record.centre_of_mass, + s.skeleton.record.centre_of_mass_velocity, + s.skateboard_controller.fields, + s.board_possession.state.selected_hand_424, + s.board_possession + .state + .hands + .iter() + .map(|h| h.dynamics) + .collect::>(), + physics.board.hook().drive.dynamics, + deck.rates, + s.offboard_contact.readiness(), + s.biped_ground.contact + ); + if phase == "state" || phase == "solve" { + eprintln!( + "OFFBOARD_AUDIT_POSE tick={tick} phase={phase} mapped={:?} targets={:?} physical={:?}", + s.animated_skeleton.record.pose, s.skeleton_input.drive_frames, s.skeleton.record.pose + ); + } +} diff --git a/crates/skate-game/src/physics/onboard_correction_tests.rs b/crates/skate-game/src/physics/onboard_correction_tests.rs new file mode 100644 index 00000000..08dc1788 --- /dev/null +++ b/crates/skate-game/src/physics/onboard_correction_tests.rs @@ -0,0 +1,135 @@ +//! Real owner/helper integration with private stock construction. Synthetic +//! requests isolate latch semantics; these are not a recorded failure replay. +use super::{GamePhysics, SkaterRuntime}; +use crate::physics::skeleton_input_runtime::SkeletonOwners; +use skate_core::player::offboard::board_possession::lifecycle::Effects as _; + +fn stock() -> (GamePhysics, SkaterRuntime) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let physics = GamePhysics::load(root).unwrap(); + let skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + (physics, skater) +} + +#[test] +#[ignore = "requires private stock skater, graph and collection assets"] +fn canonical_correction_consumes_once_and_retains_suppressed_requests() { + let (_, mut skater) = stock(); + let body = &mut skater.skeleton; + let correction = &mut skater.skeleton_output.correction; + let up = [0.0, 1.0, 0.0, 0.0]; + correction.observe_board([0.25, 0.0, 0.0, 0.0], [0.0; 4]); + correction.pending = true; + let before = body.record.pose; + let rates = body.bodies().map(|b| b.rates); + correction.apply(body, up, false, 1 << 18, 0); + assert!(correction.pending); + assert_eq!(body.record.pose, before); + correction.apply(body, up, true, 0, 0); + assert!(correction.pending); + assert_eq!(body.record.pose, before); + correction.apply(body, up, false, 0, 0); + assert!(!correction.pending); + assert_eq!(body.record.pose[0], before[0]); + for part in 1..24 { + assert_eq!(body.record.pose[part][3][0], before[part][3][0] + 0.25); + } + assert_eq!(body.bodies().map(|b| b.rates), rates); + let consumed = body.record.pose; + // No producer write: a later tick cannot resurrect the consumed request. + correction.apply(body, up, false, 0, 0); + assert_eq!(body.record.pose, consumed); + correction.pending = true; + correction.observe_board([2.0, 0.0, 0.0, 0.0], [0.0; 4]); + correction.apply(body, up, true, 0, 1 << 30); + assert!( + !correction.pending, + "special wipeout clears even when too large to move" + ); + assert_eq!(body.record.pose, consumed); +} + +#[test] +#[ignore = "requires private stock skater, graph and collection assets"] +fn physical_pose_reset_clears_the_canonical_correction_request() { + let (physics, mut skater) = stock(); + skater.skeleton_output.correction.pending = true; + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater + .skeleton_input + .reset_physical_pose(&mut owners, physics.settings.step.simulation); + assert!(!skater.skeleton_output.correction.pending); +} + +#[test] +#[ignore = "requires private stock skater, graph and collection assets"] +fn ground_entry_restores_stock_materials_and_volumes_after_release() { + let (mut physics, mut skater) = stock(); + let standard = [ + physics.settings.standard_wheel_material, + physics.settings.truck_material, + physics.settings.deck_material, + ]; + let p = &skater.player_input.processed; + skater.player_input.toolkit = Some( + skate_core::physics::board_toolkit::BoardToolkit::from_board( + &physics.board, + p.flags_2468, + p.scalar_2612, + p.vectors_464_480_496_512_528[0].map(f32::from_bits), + [0.0, 1.0, 0.0, 0.0], + ), + ); + { + let mut effects = skater.board_possession_live.effects( + &mut physics, + &mut skater.ground_lifecycle.board_animated_290, + skater.player_input.processed.timestep_2604, + ); + effects.released_board(); + effects.collision_volumes(false); + effects.enable_animation_soft(); + } + skater.board_possession_live.publish_volumes(&mut physics); + assert!(physics.board_wiping_out); + assert_eq!(physics.board.collision_group(), 7); + super::enter(&mut physics, &mut skater).unwrap(); + assert!(!physics.board_wiping_out); + assert_eq!(physics.board.collision_group(), 4); + assert_eq!( + [ + physics.settings.wheel_material, + physics.settings.truck_material, + physics.settings.deck_material + ], + standard + ); + let volumes = &skater.board_possession_live.volumes; + assert!(volumes.deck && volumes.trucks && volumes.wheels); + assert!(volumes.deck_children.iter().all(|&enabled| enabled)); + assert!(physics.settings.truck_collisions); + assert!( + physics + .settings + .deck_geometry + .children + .iter() + .all(|c| c.collision_enabled) + ); + assert_eq!(skater.ground_lifecycle.board_animated_290, 0); + assert_eq!( + physics.board.hook().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2] + ); +} diff --git a/crates/skate-game/src/physics/player_input/grind.rs b/crates/skate-game/src/physics/player_input/grind.rs index 5b4609a0..b83793da 100644 --- a/crates/skate-game/src/physics/player_input/grind.rs +++ b/crates/skate-game/src/physics/player_input/grind.rs @@ -1,10 +1,113 @@ -//! Input timing/gates from82D8A828/82D8ABD8. The geometry query/scorer runs -//! in physics::grind after input completion, before the state selector. -use skate_core::{player::input_phase::ProcessedPhysicsInput, point_graph::PointGraph}; +//! Host manager82D8A828/82D8AB08, using CURRENT player-input ownership. +//! Static authored-world scope. Gameplay leaves live in skate-core; world +//! filtering, primitive provenance and board material application stay explicit. +mod history; +mod post; +mod pre; +mod publication; +mod settings; +#[cfg(test)] +mod tests; +pub(crate) mod world; + +use crate::{grind_world::StaticProvider, physics::grind::ManagerObservation}; +use skate_core::{ + air::trajectory::grind_surface::{self, GrindSurface, Probe, ProbeHit}, + physics::{ + board_world::BoardWorld, + grind_contact::{balance, control, entry, manager}, + }, + player::input_phase::{GrindInvestigationFields, ProcessedPhysicsInput}, +}; use skate_data::collections::Collections; +type V = [f32; 4]; + +/// These are real physical/animation producers omitted from the old shared +/// input subset. There are deliberately no guessed defaults. #[derive(Clone, Copy, Debug)] -pub(crate) struct NoGrindEdges; -#[derive(Debug)] +pub(crate) struct PreContext { + /// Physical Processed64..127, not effective animation transform192. + pub board: [V; 4], + pub air_counter: i32, + pub tip_state: u32, + pub air_targeting_grind_9653: bool, + pub balance_2720: f32, + pub translation_2796: f32, + pub stability_nudge_2800: f32, + pub up_down_2804: f32, + pub grab_min_height_2808: f32, +} + +///Fresh physical/animation values read by82D8AB08 after intervening producers. +///Candidate selection and submitted geometry hits remain cached in Pending. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PostContext { + ///Current physical Processed64..127, not animation transform192. + pub board: [V; 4], + pub balance_2720: f32, + pub translation_2796: f32, + pub stability_nudge_2800: f32, + pub up_down_2804: f32, + pub grab_min_height_2808: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MaterialMode { + ///82D89DC0 leaves materials and its air-target history untouched. + Unchanged, + ///Restore the initialized standard material triplet (82D89DC0). + ///Not the broader SetStandard routine's unrelated body/volume writes. + Standard, + ///All parts group4; wheels board8316, deck8328, trucks8340. + Grind, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct PrimitiveMetadata { + pub spline_guids: Option<[u64; 2]>, +} + +/// Callbacks execute the original grind line-batch contract against current +/// world metadata (see world), not the contact-fatness BoardWorld line query. +/// Query errors propagate, never become misses. The host owns actual body and +/// material tables; a material mode must be applied before this call returns. +pub(crate) trait Host { + fn apply_material_mode(&mut self, mode: MaterialMode) -> Result<(), String>; + fn surface_probe( + &mut self, + world: &BoardWorld, + actor: [u32; 2], + index: usize, + probe: Probe, + ) -> Result, String>; + fn force_exit_line( + &mut self, + world: &BoardWorld, + actor: [u32; 2], + probe: balance::ForceExitProbe, + ) -> Result, String>; +} + +/// Not Clone: the caller consumes one pre result in one post phase. +pub(crate) struct Pending { + fields: GrindInvestigationFields, + geometry: Option, + metadata: Option, +} +struct GeometryWork { + input: grind_surface::InvestigationInput, + plan: Option, + hits: [Option; 7], +} + +pub(crate) struct PostResult { + /// One entry per native Request call, including two simultaneous impacts. + /// Main delivers these through its actual wipeout manager (which updates + /// reason flags, timers and request count); processed bit18 is set here. + pub wipeout_reasons: Vec, + pub observation: ManagerObservation, +} + pub(crate) struct GrindInputState { previous_state: u32, engagement_counter: u32, @@ -19,17 +122,21 @@ pub(crate) struct GrindInputState { pub grounded_frames: u32, pub air_frames: u32, pub low_wheel_frames: u32, - previous_candidate: bool, - curve: PointGraph<4>, + previous_proximity: bool, + previous_air_target: bool, + previous_direction: V, + gravity_timer: f32, + pub balance: balance::BalanceState, + pub engagement: entry::Engagement, + pub control: control::Control, + /// Physical pop consumes this same cache/energy/cooldown, not a copy. + pub jumper: manager::Jumper, + pub investigation: GrindInvestigationFields, + settings: settings::Settings, } + impl GrindInputState { pub fn load(data: &Collections) -> Result { - //XML C7220-C70E0=320; native PointGraph X336/Y352 skips header16. - let graph = data - .words::<12>("physics_grinds", "default", "FrictionVsTime")? - .map(f32::from_bits); - //82D8A318 scorer storesC/10/14/18/1C/20 and48 tozero;82D8A638 - //resets main464/468/472/476/477/480/484 and all416outputbytes. Ok(Self { previous_state: 0, engagement_counter: 0, @@ -44,82 +151,50 @@ impl GrindInputState { grounded_frames: 0, air_frames: 0, low_wheel_frames: 0, - previous_candidate: false, - curve: PointGraph { - x: graph[4..8].try_into().unwrap(), - y: graph[8..12].try_into().unwrap(), - }, + previous_proximity: false, + previous_air_target: false, + previous_direction: [0.; 4], + gravity_timer: 0., + balance: balance::BalanceState::default(), + engagement: entry::Engagement::default(), + control: control::Control::default(), + jumper: manager::Jumper::default(), + investigation: GrindInvestigationFields::default(), + settings: settings::Settings::load(data)?, }) } - pub fn update( - &mut self, - _world: NoGrindEdges, - p: &ProcessedPhysicsInput, - air_counter: i32, - ) -> Result<(), String> { - let state = p.state_2508; - if state != self.previous_state { - self.engagement_counter = self.engagement_counter.wrapping_add(match state { - 400 | 402 | 404 => 50, - 403 => 20, - _ => 0, - }); - self.previous_state = state; - } - self.engagement_counter = decrement(self.engagement_counter); - self.suppressed = self.engagement_counter > 151; - self.cooldown = if self.suppressed { - 90 - } else { - decrement(self.cooldown) - }; - self.disabled = p.flags_2468 & 0x1800_0000 != 0 - || p.flags_2472 & 0x8008 != 0 - || p.flags_2476 & 0x0040_0000 != 0 - || self.cooldown > 0 - || (p.category_2512 == 200 - && (p.state_timer_2664 <= f32::from_bits(0x3da3_d70a) - || air_counter <= 10)) - || p.category_2512 == 500 - || (p.flags_2472 & 4 != 0 && p.category_2512 != 400); - self.elapsed = if p.category_2512 == 400 || state == 701 { - self.elapsed + p.timestep_2604 - } else { - 0. - }; - self.low_wheel_frames = if self.previous_candidate - && state == 100 - && p.wheel_count_2556 < 2 - && p.scalar_2652 < 0.8 - { - self.low_wheel_frames.wrapping_add(1) - } else { - 0 - }; - self.grind_history = if p.grind_words_2532_2536[1] == 2 { - 70 - } else { - decrement(self.grind_history) - }; - self.secondary_history = decrement(self.secondary_history); - self.grounded_frames = if p.category_2512 == 100 { - self.grounded_frames.wrapping_add(1) - } else { - 0 - }; - self.air_frames = if p.category_2512 == 100 { - 0 - } else { - self.air_frames.wrapping_add(1) - }; - //82D875A8 LABEL80 on edge count0 clears candidate384 and owner32. - self.previous_candidate = false; - self.previous_velocity = p.vectors_400_416[0]; - self.friction_vs_time = self.curve.evaluate(self.elapsed); - Ok(()) + + ///82D8A638 resets the manager, not the separately constructed child objects. + pub fn reset(&mut self) { + self.previous_state = 0; + self.engagement_counter = 0; + self.cooldown = 0; + self.disabled = false; + self.suppressed = false; + self.elapsed = 0.; + self.gravity_timer = 0.; + self.friction_vs_time = 0.; + self.previous_direction = [0.; 4]; + self.investigation = GrindInvestigationFields::default(); } } -fn decrement(value: u32) -> u32 { - let next = value.wrapping_sub(1); - if next & 0x8000_0000 != 0 { 0 } else { next } + +fn float(v: [u32; 4]) -> V { + v.map(f32::from_bits) +} +fn raw(v: V) -> [u32; 4] { + v.map(f32::to_bits) +} + +impl core::fmt::Debug for GrindInputState { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("GrindInputState") + .field("disabled", &self.disabled) + .field("suppressed", &self.suppressed) + .field("investigation", &self.investigation) + .field("balance", &self.balance) + .field("engagement", &self.engagement) + .field("jumper", &self.jumper) + .finish_non_exhaustive() + } } diff --git a/crates/skate-game/src/physics/player_input/grind/history.rs b/crates/skate-game/src/physics/player_input/grind/history.rs new file mode 100644 index 00000000..a078f203 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/history.rs @@ -0,0 +1,93 @@ +use super::*; + +impl GrindInputState { + ///82D8A828/82D8ABD8; preserved current permission/counter arithmetic. + pub(super) fn permission(&mut self, p: &ProcessedPhysicsInput, air_counter: i32) { + let state = p.state_2508; + if state != self.previous_state { + self.engagement_counter = self.engagement_counter.wrapping_add(match state { + 400 | 402 | 404 => 50, + 403 => 20, + _ => 0, + }); + self.previous_state = state; + } + self.engagement_counter = decrement(self.engagement_counter); + self.suppressed = self.engagement_counter > 151; + self.cooldown = if self.suppressed { + 90 + } else { + decrement(self.cooldown) + }; + self.disabled = p.flags_2468 & 0x1800_0000 != 0 + || p.flags_2472 & 0x8008 != 0 + || p.flags_2476 & 0x0040_0000 != 0 + || self.cooldown > 0 + || (p.category_2512 == 200 + && (p.state_timer_2664 <= f32::from_bits(0x3da3_d70a) || air_counter <= 10)) + || p.category_2512 == 500 + || (p.flags_2472 & 4 != 0 && p.category_2512 != 400); + self.elapsed = if p.category_2512 == 400 || state == 701 { + self.elapsed + p.timestep_2604 + } else { + 0. + }; + } + + ///82D875A8; accepted families RETAIN previous proximity. Only fallback + ///writes the history byte, which is not the valid-candidate flag. + pub(super) fn advance_history(&mut self, p: &ProcessedPhysicsInput) { + self.low_wheel_frames = if self.previous_proximity + && p.state_2508 == 100 + && (p.wheel_count_2556 as i32) < 2 + && p.scalar_2652 < 0.8 + { + self.low_wheel_frames.wrapping_add(1) + } else { + 0 + }; + self.grind_history = if p.grind_words_2532_2536[1] == 2 { + 70 + } else { + decrement(self.grind_history) + }; + self.secondary_history = decrement(self.secondary_history); + self.grounded_frames = if p.category_2512 == 100 { + self.grounded_frames.wrapping_add(1) + } else { + 0 + }; + self.air_frames = if p.category_2512 == 100 { + 0 + } else { + self.air_frames.wrapping_add(1) + }; + } + + ///82D89DC0: use the PRECEDING KnownAir target on ground, then update it. + pub(super) fn material_mode( + &mut self, + p: &ProcessedPhysicsInput, + nearby: bool, + targeting: bool, + ) -> MaterialMode { + if p.state_2508 == 300 || p.category_2512 == 500 { + return MaterialMode::Unchanged; + } + let grind = (nearby && matches!(p.state_2508, 200 | 201)) + || p.category_2512 == 400 + || p.state_2508 == 701 + || (p.category_2512 == 100 && self.previous_air_target); + self.previous_air_target = p.state_2508 == 201 && targeting; + if grind { + MaterialMode::Grind + } else { + MaterialMode::Standard + } + } +} + +pub(super) fn decrement(value: u32) -> u32 { + let next = value.wrapping_sub(1); + if next & 0x8000_0000 != 0 { 0 } else { next } +} diff --git a/crates/skate-game/src/physics/player_input/grind/post.rs b/crates/skate-game/src/physics/player_input/grind/post.rs new file mode 100644 index 00000000..560e969c --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/post.rs @@ -0,0 +1,202 @@ +use super::*; +use skate_core::physics::grind_contact::admission::EntryKind; + +impl GrindInputState { + ///82D8AB08's meaningful order. Geometry consumes the pre-submitted hits; + /// material application has already happened. The complete snapshot is + /// copied only after balance, engagement, controls and jumper publication. + pub fn post_update( + &mut self, + p: &mut ProcessedPhysicsInput, + world: &BoardWorld, + pending: Pending, + context: PostContext, + host: &mut impl Host, + ) -> Result { + let Pending { + mut fields, + geometry, + metadata, + } = pending; + let surface = if let Some(work) = geometry { + let surface = if let Some(plan) = &work.plan { + grind_surface::resolve(plan, &work.hits, fields.geometry_flags_1476) + } else { + //The original inadequate-up gate returns its specific + //not-submitted result. Reuse the core branch, not a fake miss. + grind_surface::investigate(work.input, |index, probe| { + host.surface_probe( + world, + [p.actor_query_2948, p.actor_query_2952], + index, + probe, + ) + })? + }; + publication::surface(&mut fields, &surface); + let output = manager::tweak_geometry( + manager::GeometryInput { + valid: fields.valid_1488, + family: fields.family_1248, + current_state: p.state_2508, + category: p.category_2512, + air_frames: self.air_frames, + geometry_kind: surface.kind as u32, + geometry_flags: surface.flags, + far_points: surface.far_points, + upmost: surface.upmost_normal, + high_side: surface.high_side, + point: float(fields.point_1120), + direction: float(fields.direction_1136), + board_position: context.board[3], + board_forward: context.board[2], + velocity: float(p.vectors_400_416[0]), + deck_to_truck: self.settings.deck_to_truck, + previous_exit_angle: self.balance.exit_angle_degrees, + previous_exit_direction: self.balance.exit_direction, + flags: fields.flags_1516, + }, + &mut self.secondary_history, + ); + fields.valid_1488 = output.valid; + fields.family_1248 = output.family; + fields.flags_1516 = output.flags; + Some(surface) + } else { + None + }; + + fields.gravity_relief_1512 = manager::gravity_relief( + &mut self.gravity_timer, + fields.valid_1488, + p.category_2512, + float(fields.tangent_1104), + float(p.vectors_400_416[0]), + p.timestep_2604, + &self.settings.gravity_vertical, + &self.settings.gravity_linear, + ); + let contact = match surface.as_ref().filter(|_| fields.valid_1488) { + Some(surface) => Some(balance::BalanceContact { + surface, + primitive_direction: float(fields.tangent_1104), + directed_grind_direction: float(fields.direction_1136), + kind: fields.family_1248, + entry_kind: entry_kind(fields.entry_kind_1252)?, + }), + None => None, + }; + //Zeroed each pre frame by the original investigation reset. Invalid + //target-up updates leave these zeros; history lives in BalanceState. + let mut vectors = balance::BalanceVectors { + grind_normal: float(fields.normal_1152), + target_up: float(fields.target_up_1168), + }; + self.balance.update_target_up( + balance::TargetUpInput { + category: p.category_2512, + previous_state: p.state_2504, + board_up: float(p.vectors_544_560_592_608[0]), + }, + contact.as_ref(), + &mut vectors, + ); + fields.exit_lean_1500 = self.balance.update_exit_lean( + balance::ExitLeanInput { + category: p.category_2512, + current_state: p.state_2508, + grind_substate: p.grind_words_2532_2536[1], + timestep: p.timestep_2604, + }, + contact.as_ref(), + &self.settings.exit_lean, + &mut vectors, + ); + fields.normal_1152 = raw(vectors.grind_normal); + fields.target_up_1168 = raw(vectors.target_up); + self.balance.update_force_exit( + float(fields.point_1120), + &mut fields.flags_1516, + |probe| host.force_exit_line(world, [p.actor_query_2948, p.actor_query_2952], probe), + )?; + + let engagement = self.engagement.update(entry::Input { + valid: fields.valid_1488, + kind: fields.family_1248, + category: p.category_2512, + previous_state_2504: p.state_2504, + speed: p.scalar_2652, + balance_2720: context.balance_2720, + direction: float(fields.direction_1136), + normal: vectors.grind_normal, + up: float(p.vectors_544_560_592_608[0]), + board_velocity: float(p.vectors_400_416[0]), + air_velocity: float(p.vectors_544_560_592_608[3]), + surface_kind: fields.geometry_kind_1464, + high_side: float(fields.high_side_1440), + vertical_help: &self.settings.vertical_help, + max_delta: self.settings.max_impact, + flags: fields.flags_1516, + previous_entry_velocity: float(fields.entry_velocity_1184), + }); + fields.valid_1488 = engagement.valid; + fields.flags_1516 = engagement.flags; + fields.entry_velocity_1184 = raw(engagement.entry_velocity); + fields.impact_speed_1492 = engagement.impact_speed; + if !engagement.wipeout_reasons.is_empty() { + p.flags_2468 |= 0x0004_0000; + } + //Control::update's other-family branch is exactly the invalid-candidate + //zero-target smoothing branch. Do not feed reset family0 as a50-50. + self.control.update( + if fields.valid_1488 { + fields.family_1248 + } else { + u32::MAX + }, + context.board[2], + vectors.grind_normal, + fields.flags_1516 & 0x2000_0000 != 0, + p.flags_2468 & 0x0010_0000 != 0, + fields.flags_1516 & 0x1000_0000 != 0, + context.translation_2796, + context.stability_nudge_2800, + context.up_down_2804, + context.grab_min_height_2808, + ); + fields.yaw_1504 = self.control.yaw; + fields.pitch_1508 = self.control.pitch; + let jump_geometry = fields.valid_1488.then(|| manager::JumpGeometry { + geometry_kind: fields.geometry_kind_1464, + high_side: float(fields.high_side_1440), + normal: vectors.grind_normal, + direction: float(fields.direction_1136), + upmost: float(fields.upmost_normal_1408), + point: float(fields.point_1120), + }); + p.flags_2476 |= self + .jumper + .update(jump_geometry, p.grind_words_2532_2536[0]); + self.investigation = fields; + p.grind = fields; + let observation = publication::observation(p, context, metadata, &self.jumper)?; + Ok(PostResult { + wipeout_reasons: engagement.wipeout_reasons, + observation, + }) + } +} + +fn entry_kind(value: u32) -> Result { + //Only reset0 or a typed investigator decision reaches this adapter. + Ok(match value { + 0 => EntryKind::RideFromAbove, + 1 => EntryKind::RideFromBelow, + 2 => EntryKind::RideIntoCoping, + 3 => EntryKind::StayInGrind, + 4 => EntryKind::ChangeGrind, + 5 => EntryKind::AirToGrind, + 6 => EntryKind::DropIn, + _ => return Err(format!("Invalid published grind admission kind {value}")), + }) +} diff --git a/crates/skate-game/src/physics/player_input/grind/pre.rs b/crates/skate-game/src/physics/player_input/grind/pre.rs new file mode 100644 index 00000000..01132b5d --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/pre.rs @@ -0,0 +1,159 @@ +use super::*; +use skate_core::physics::grind_contact::{admission::Admission, investigator}; + +impl GrindInputState { + ///82D8A828: permission -> bounded static query -> materials -> investigator + ///-> directed tangent -> previous velocity -> friction -> geometry probes. + /// Main may run other input producers between this and post_update. + pub fn pre_update( + &mut self, + p: &ProcessedPhysicsInput, + provider: &StaticProvider, + world: &BoardWorld, + context: PreContext, + host: &mut impl Host, + ) -> Result { + self.previous_direction = float(self.investigation.direction_1136); + self.investigation = GrindInvestigationFields::default(); + self.permission(p, context.air_counter); + let indices = if p.flags_2476 & 0x0100_0000 != 0 { + Vec::new() + } else { + provider.query( + core::array::from_fn(|i| context.board[3][i] - 1.2), + core::array::from_fn(|i| context.board[3][i] + 1.2), + )? + }; + let mode = self.material_mode(p, !indices.is_empty(), context.air_targeting_grind_9653); + host.apply_material_mode(mode)?; + self.advance_history(p); + let edges = indices + .iter() + .map(|&i| provider.primitives()[i]) + .collect::>(); + let result = investigator::investigate( + &investigator::Query { + board: context.board, + admission: Admission { + category: p.category_2512, + state: p.state_2508, + speed: p.scalar_2652, + velocity: float(p.vectors_400_416[0]), + threshold_vs_slope: &self.settings.slope_threshold, + }, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2484: p.flags_2484, + tip_state: context.tip_state, + truck_to_wheel: self.settings.truck_to_wheel, + deck_to_truck: self.settings.deck_to_truck, + test_above: self.settings.test_above, + test_below: self.settings.test_below, + translation: context.translation_2796, + stability_nudge: context.stability_nudge_2800, + balance: context.balance_2720, + reference_right: float(p.vectors_464_480_496_512_528[0]), + ground_frames: self.grounded_frames, + low_wheel_frames: self.low_wheel_frames, + forbidden: self.disabled + || (self.grind_history != 0 && p.grind_words_2532_2536[1] != 2), + }, + &edges, + ); + let mut fields = GrindInvestigationFields::default(); + let mut metadata = None; + let mut geometry = None; + if let Some(contact) = result.candidate { + let g = contact.geometry; + let edge = edges[g.primitive]; + let native = provider + .metadata(indices[g.primitive]) + .ok_or("Selected static grind primitive lacks metadata")?; + metadata = Some(PrimitiveMetadata { + spline_guids: Some(native.spline_guids), + }); + fields.valid_1488 = true; + fields.family_1248 = contact.kind; + fields.entry_kind_1252 = contact.entry_kind as u32; + fields.tangent_1104 = raw(g.direction); + fields.point_1120 = raw(g.centre); + fields.direction_1136 = raw(manager::directed_tangent( + edge.start, + edge.end, + float(p.vectors_400_416[0]), + self.previous_direction, + )); + fields.primitive_start_1264 = raw(edge.start); + fields.primitive_end_1280 = raw(edge.end); + fields.owner_1296 = Some(edge.owner); + fields.primitive_flags_1300 = native.flags & 0x8000_0000; + fields.flags_1516 = if contact.front { 0x2000_0000 } else { 0 }; + //82D8939C/A0 writes both truck hits. FiveO82D89CB8/9D5C + //writes the selected hit to+96 regardless of front/rear selection. + if contact.kind == 0 { + fields.front_contact_1200 = raw(g.front); + fields.rear_contact_1216 = raw(g.rear); + } else if contact.kind == 3 { + fields.front_contact_1200 = raw(g.centre); + } + let input = grind_surface::InvestigationInput { + start: edge.start, + end: edge.end, + reference: g.centre, + optional_probe: (p.state_2508 == 402).then_some(context.board[3]), + deck_center_to_truck: self.settings.deck_to_truck, + }; + let plan = grind_surface::prepare(input); + geometry = Some(GeometryWork { + input, + plan, + hits: [None; 7], + }); + //Accepted family returns before investigator's history-byte write. + } else { + self.previous_proximity = result.proximity.is_some(); + if let Some(proximity) = result.proximity { + let edge = edges[proximity.contact.primitive]; + let native = provider + .metadata(indices[proximity.contact.primitive]) + .ok_or("Static grind proximity lacks metadata")?; + //82D87E18/34/3C: contact+128 and SECOND primitive+208/+224. + fields.vector_1232 = raw(proximity.contact.position); + fields.second_start_1312 = raw(edge.start); + fields.second_end_1328 = raw(edge.end); + fields.second_owner_1344 = Some(edge.owner); + fields.second_flags_1348 = native.flags & 0x8000_0000; + fields.flags_1516 = 0x0800_0000 + | if proximity.deck_contact { + 0x0400_0000 + } else { + 0 + }; + } + } + self.previous_velocity = p.vectors_400_416[0]; + self.friction_vs_time = self.settings.friction.evaluate(self.elapsed); + fields.friction_1496 = self.friction_vs_time; + self.investigation = fields; + //82D8AA54..82D8AAF8 publishes previous velocity and friction before + //submitting geometry descriptors. Post resolves these exact results. + if let Some(work) = &mut geometry { + if let Some(plan) = &work.plan { + for (index, &probe) in plan.probes.iter().enumerate() { + work.hits[index] = host.surface_probe( + world, + [p.actor_query_2948, p.actor_query_2952], + index, + probe, + )?; + } + } + } + Ok(Pending { + fields, + geometry, + metadata, + }) + } +} diff --git a/crates/skate-game/src/physics/player_input/grind/publication.rs b/crates/skate-game/src/physics/player_input/grind/publication.rs new file mode 100644 index 00000000..9debb900 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/publication.rs @@ -0,0 +1,82 @@ +use super::*; +use crate::physics::grind::{Family, observation::*}; + +pub(super) fn surface(fields: &mut GrindInvestigationFields, surface: &GrindSurface) { + fields.center_1360 = raw(surface.center); + fields.far_points_1376_1392 = surface.far_points.map(raw); + fields.upmost_normal_1408 = raw(surface.upmost_normal); + fields.surface_direction_1424 = raw(surface.direction); + fields.high_side_1440 = raw(surface.high_side); + fields.normal_limits_1456_1460 = surface.normal_limits; + fields.geometry_kind_1464 = surface.kind as u32; + fields.audio_surface_1468 = surface.audio_surface; + fields.physics_surface_1472 = surface.physics_surface; + fields.geometry_flags_1476 = surface.flags; +} + +pub(super) fn observation( + p: &ProcessedPhysicsInput, + c: PostContext, + metadata: Option, + jumper: &manager::Jumper, +) -> Result { + let f = &p.grind; + Ok(ManagerObservation { + geometry: GeometryObservation { + point_1120: float(f.point_1120), + direction_1136: float(f.direction_1136), + normal_1152: float(f.normal_1152), + target_up_1168: float(f.target_up_1168), + primitive_start_1264: float(f.primitive_start_1264), + primitive_end_1280: float(f.primitive_end_1280), + spline_guids_1296: metadata.and_then(|m| m.spline_guids), + upmost_normal_1408: float(f.upmost_normal_1408), + high_side_1440: float(f.high_side_1440), + kind_1464: f.geometry_kind_1464, + flags_1476: f.geometry_flags_1476, + impact_speed_1492: f.impact_speed_1492, + }, + surface: SurfaceObservation { + audio_surface_1468: f.audio_surface_1468, + material_1472: f.physics_surface_1472, + friction_vs_time_1496: f.friction_1496, + reckon_blend_selector_1500: f.exit_lean_1500, + gravity_relief_1512: f.gravity_relief_1512, + }, + control: ControlObservation { + family: family(f.family_1248)?, + flags_1516: f.flags_1516, + flags_2468: p.flags_2468, + flags_2488: p.flags_2488, + translation_2796: c.translation_2796, + balance_2800: c.stability_nudge_2800, + exit_lean: f.exit_lean_1500, + }, + engagement: EngagementObservation { + velocity_1184: float(f.entry_velocity_1184), + kind_1248: f.family_1248, + }, + jumper: JumperObservation { + geometry_kind_16: jumper.geometry.geometry_kind, + family_20: family(jumper.family)?, + energy_24: jumper.energy, + high_side_32: jumper.geometry.high_side, + normal_48: jumper.geometry.normal, + direction_64: jumper.geometry.direction, + upmost_normal_80: jumper.geometry.upmost, + point_96: jumper.geometry.point, + }, + }) +} + +fn family(value: u32) -> Result { + Ok(match value { + 0 => Family::FiftyFifty, + 1 => Family::Boardslide, + 2 => Family::Tipslide, + 3 => Family::FiveO, + 4 => Family::Backslash, + 5 => Family::Darkslide, + _ => return Err(format!("Invalid published grind family {value}")), + }) +} diff --git a/crates/skate-game/src/physics/player_input/grind/settings.rs b/crates/skate-game/src/physics/player_input/grind/settings.rs new file mode 100644 index 00000000..ec49232f --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/settings.rs @@ -0,0 +1,57 @@ +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; + +pub(super) struct Settings { + pub friction: PointGraph<4>, + pub slope_threshold: PointGraph<4>, + pub vertical_help: PointGraph<4>, + pub gravity_vertical: PointGraph<4>, + pub gravity_linear: PointGraph<4>, + pub exit_lean: PointGraph<8>, + pub truck_to_wheel: f32, + pub deck_to_truck: f32, + pub test_above: f32, + pub test_below: f32, + pub max_impact: f32, +} + +impl Settings { + pub fn load(data: &Collections) -> Result { + let f = |name| data.float("physics_grinds", "default", name); + let exit = data + .words::<20>("physics_grinds", "default", "ExitLeanAngleVsTime")? + .map(f32::from_bits); + Ok(Self { + friction: graph4(data, "FrictionVsTime")?, + slope_threshold: graph4(data, "DVEntryThreshScalarVsSinSlope")?, + vertical_help: graph4(data, "VertEngagementHelpVsVelY")?, + gravity_vertical: graph4(data, "TimeOfGravityReliefVsVerticalSpeed")?, + gravity_linear: graph4(data, "GravityReliefTimeVsLinearSpeed")?, + exit_lean: PointGraph { + x: exit[4..12].try_into().unwrap(), + y: exit[12..20].try_into().unwrap(), + }, + truck_to_wheel: f("TruckToWheel")?, + deck_to_truck: f("DeckCenterToTruck")?, + test_above: f("TestDepthEpsilon")?, + test_below: f("TestDepth")?, + //physics_wipeout layout256,82D87358; not trajectory grind speed. + max_impact: data.float( + "physics_wipeout", + "default", + "Wipeout_AirMaxSpeedIntoCollisionNearGrind", + )?, + }) + } +} + +fn graph4(data: &Collections, name: &str) -> Result, String> { + let words = data + .words::<12>("physics_grinds", "default", name)? + .map(f32::from_bits); + //PointNegGraphData4 has a16-byte display-domain header before X/Y. + Ok(PointGraph { + x: words[4..8].try_into().unwrap(), + y: words[8..12].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/player_input/grind/tests.rs b/crates/skate-game/src/physics/player_input/grind/tests.rs new file mode 100644 index 00000000..4592d04b --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/tests.rs @@ -0,0 +1,243 @@ +use super::*; +use skate_core::point_graph::PointGraph; + +fn state() -> GrindInputState { + let constant = |value| PointGraph { + x: [0., 1., 2., 3.], + y: [value; 4], + }; + GrindInputState { + previous_state: 0, + engagement_counter: 0, + cooldown: 0, + disabled: false, + suppressed: false, + elapsed: 0., + friction_vs_time: 0., + previous_velocity: [0; 4], + grind_history: 0, + secondary_history: 0, + grounded_frames: 0, + air_frames: 0, + low_wheel_frames: 0, + previous_proximity: false, + previous_air_target: false, + previous_direction: [0.; 4], + gravity_timer: 0., + balance: balance::BalanceState::default(), + engagement: entry::Engagement::default(), + control: control::Control::default(), + jumper: manager::Jumper::default(), + investigation: GrindInvestigationFields::default(), + settings: settings::Settings { + friction: constant(0.8), + slope_threshold: constant(1.), + vertical_help: constant(0.5), + gravity_vertical: constant(2.), + gravity_linear: constant(0.5), + exit_lean: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.; 8], + }, + truck_to_wheel: 0.095, + deck_to_truck: 0.243, + test_above: 0.03, + test_below: 0.2, + max_impact: 8., + }, + } +} + +fn context() -> PreContext { + PreContext { + board: [ + [1., 0., 0., 0.], + [0., 1., 0., 0.], + [0., 0., 1., 0.], + [0., 0., 0., 1.], + ], + air_counter: 20, + tip_state: 0, + air_targeting_grind_9653: false, + balance_2720: 0., + translation_2796: 0., + stability_nudge_2800: 0., + up_down_2804: 0., + grab_min_height_2808: 0., + } +} + +#[derive(Default)] +struct TestHost { + events: Vec<&'static str>, + fail: bool, +} +impl Host for TestHost { + fn apply_material_mode(&mut self, mode: MaterialMode) -> Result<(), String> { + self.events.push(match mode { + MaterialMode::Grind => "grind", + MaterialMode::Standard => "standard", + MaterialMode::Unchanged => "unchanged", + }); + if self.fail { + Err("material failure".into()) + } else { + Ok(()) + } + } + fn surface_probe( + &mut self, + _: &BoardWorld, + _: [u32; 2], + _: usize, + _: Probe, + ) -> Result, String> { + self.events.push("surface"); + if self.fail { + Err("query failure".into()) + } else { + Ok(None) + } + } + fn force_exit_line( + &mut self, + _: &BoardWorld, + _: [u32; 2], + _: balance::ForceExitProbe, + ) -> Result, String> { + self.events.push("exit"); + if self.fail { + Err("query failure".into()) + } else { + Ok(None) + } + } +} + +#[test] +fn material_selection_uses_previous_air_target_and_preserves_skipped_history() { + let mut s = state(); + let mut p = ProcessedPhysicsInput::default(); + p.state_2508 = 201; + p.category_2512 = 200; + assert_eq!(s.material_mode(&p, false, true), MaterialMode::Standard); + p.state_2508 = 300; + assert_eq!(s.material_mode(&p, true, false), MaterialMode::Unchanged); + assert!(s.previous_air_target); + p.state_2508 = 100; + p.category_2512 = 100; + assert_eq!(s.material_mode(&p, false, false), MaterialMode::Grind); + assert_eq!(s.material_mode(&p, false, false), MaterialMode::Standard); +} + +#[test] +fn permission_keeps_flicker_weights_strict_threshold_and_signed_decrement() { + let mut s = state(); + let mut p = ProcessedPhysicsInput::default(); + p.state_2508 = 400; + s.permission(&p, 20); + assert_eq!(s.engagement_counter, 49); + p.state_2508 = 403; + s.permission(&p, 20); + assert_eq!(s.engagement_counter, 68); + s.engagement_counter = 152; + s.permission(&p, 20); + assert!(!s.suppressed); + s.engagement_counter = 153; + s.permission(&p, 20); + assert!(s.suppressed && s.disabled); + assert_eq!(s.cooldown, 90); + assert_eq!(history::decrement(0), 0); + assert_eq!(history::decrement(u32::MAX), 0); + assert_eq!(history::decrement(0x8000_0000), 0x7fff_ffff); +} + +#[test] +fn constructor_reset_does_not_reinitialize_child_history_or_jump_energy() { + let mut s = state(); + s.jumper.energy = 0.6; + s.balance.elapsed = 1.; + s.secondary_history = 15; + s.investigation.flags_1516 = u32::MAX; + s.reset(); + assert_eq!(s.investigation, GrindInvestigationFields::default()); + assert_eq!(s.jumper.energy, 0.6); + assert_eq!(s.balance.elapsed, 1.); + assert_eq!(s.secondary_history, 15); +} + +#[test] +fn empty_authored_world_completes_active_grind_without_error_stub() { + let mut s = state(); + let mut p = ProcessedPhysicsInput::default(); + p.state_2508 = 403; + p.category_2512 = 400; + p.timestep_2604 = 0.02; + p.grind_words_2532_2536 = [3, 0]; + let provider = StaticProvider::new(None).unwrap(); + let world = BoardWorld::new(vec![]); + let mut host = TestHost::default(); + let pending = s + .pre_update(&p, &provider, &world, context(), &mut host) + .unwrap(); + let c = context(); + let post = PostContext { + board: c.board, + balance_2720: c.balance_2720, + translation_2796: c.translation_2796, + stability_nudge_2800: c.stability_nudge_2800, + up_down_2804: c.up_down_2804, + grab_min_height_2808: c.grab_min_height_2808, + }; + let result = s + .post_update(&mut p, &world, pending, post, &mut host) + .unwrap(); + assert!(!p.grind.valid_1488); + assert_eq!(p.grind.friction_1496, 0.8); + assert_eq!(p.grind.normal_1152, [0; 4]); + assert_eq!(host.events, ["grind"]); + assert!(result.wipeout_reasons.is_empty()); + assert_eq!(result.observation.jumper.family_20 as u32, 3); +} + +#[test] +fn producer_failure_is_not_a_query_miss() { + let mut s = state(); + let p = ProcessedPhysicsInput::default(); + let provider = StaticProvider::new(None).unwrap(); + let world = BoardWorld::new(vec![]); + let mut host = TestHost { + fail: true, + ..TestHost::default() + }; + assert!( + s.pre_update(&p, &provider, &world, context(), &mut host) + .is_err() + ); + assert_eq!(s.grounded_frames, 0); +} + +#[test] +fn full_surface_publication_keeps_separate_normals_materials_and_offsets() { + let surface = GrindSurface { + center: [1.; 4], + far_points: [[2.; 4], [3.; 4]], + upmost_normal: [4.; 4], + direction: [5.; 4], + high_side: [6.; 4], + normal_limits: [7., 8.], + kind: grind_surface::GeometryType::Ledge, + audio_surface: 9, + physics_surface: 10, + flags: 0x1000_0000, + tilted_upmost_normal: [11.; 4], + }; + let mut fields = GrindInvestigationFields::default(); + publication::surface(&mut fields, &surface); + assert_eq!(fields.center_1360, raw([1.; 4])); + assert_eq!(fields.far_points_1376_1392, [raw([2.; 4]), raw([3.; 4])]); + assert_eq!(fields.upmost_normal_1408, raw([4.; 4])); + assert_eq!(fields.audio_surface_1468, 9); + assert_eq!(fields.physics_surface_1472, 10); + assert_eq!(fields.normal_1152, [0; 4]); +} diff --git a/crates/skate-game/src/physics/player_input/grind/world.rs b/crates/skate-game/src/physics/player_input/grind/world.rs new file mode 100644 index 00000000..cb625cc6 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/world.rs @@ -0,0 +1,185 @@ +//! Static host for original S3 82C20728 and 82E0AAD0 line batches. +//! Both use scene mask7, matching ID2952, side flags3, rejection mask0 and +//! a null surface-exclusion table. Metadata is mandatory, never render tags. +use skate_core::{ + air::trajectory::grind_surface::{Probe, ProbeHit}, + math::Vector3, + physics::{ + board_world::{ + BoardWorld, + query_metadata::{Bounds, QueryPool}, + }, + grind_contact::balance::{ForceExitHit, ForceExitProbe}, + triangle_query::{TriangleLineHit, triangle_segment}, + }, +}; + +#[cfg(test)] +#[path = "world_tests.rs"] +mod tests; + +///82C205D8/82C20728: descriptor5 has radius .001; the others have radius0. +/// The descriptor supplies its radius, so both thin and swept leaves execute. +pub(crate) fn surface_probe( + world: &BoardWorld, + actor: [u32; 2], + index: usize, + probe: Probe, +) -> Result, String> { + if index >= 7 { + return Err(format!( + "Grind surface descriptor {index} is outside native batch7" + )); + } + line(world, actor[1] as i32, probe) +} + +///82D86C88 passes processed2952 into82E0AAD0, not actor identity2948. +pub(crate) fn force_exit_line( + world: &BoardWorld, + actor: [u32; 2], + probe: ForceExitProbe, +) -> Result, String> { + Ok(line( + world, + actor[1] as i32, + Probe { + start: probe.start, + end: probe.end, + radius: 0., + }, + )? + .map(|hit| ForceExitHit { normal: hit.normal })) +} + +fn line(world: &BoardWorld, matching: i32, probe: Probe) -> Result, String> { + let metadata = world.query_metadata().map_err(str::to_owned)?; + if !probe.radius.is_finite() || probe.radius < 0. { + return Err("Invalid grind line radius".into()); + } + let start = vector(probe.start); + let end = vector(probe.end); + let bounds = Bounds::from_points([start, end]) + .ok_or("Non-finite grind line endpoints")? + .expanded(probe.radius); + if ![ + bounds.min.x, + bounds.min.y, + bounds.min.z, + bounds.max.x, + bounds.max.y, + bounds.max.z, + ] + .into_iter() + .all(f32::is_finite) + { + return Err("Grind line bounds overflow".into()); + } + let delta = sub(end, start); + //82770650's component gate precedes dispatch, including rounded lines. + let threshold = f32::from_bits(0x3780_0000); + if !(delta.x.abs() > threshold || delta.y.abs() > threshold || delta.z.abs() > threshold) { + return Ok(None); + } + //The index is conservative; native exact gates below remain authoritative. + let candidates = world + .candidate_mesh_indices(Some(bounds)) + .map_err(str::to_owned)?; + //Current BoardWorld applies its stored per-triangle bounds filter after + //cluster culling. Collect once in canonical order, then restrict each mesh + //to its candidate slice instead of walking its entire triangle range. + let triangle_candidates = world + .line_candidates(start, end, probe.radius) + .collect::>(); + let mut nearest = f32::MAX; + let mut output = None; + //82764AB0 mask7 and8276EC40 aggregate nearest: ties retain earlier pools. + for pool in [QueryPool::Ground, QueryPool::Island, QueryPool::Conditional] { + if pool == QueryPool::Conditional && metadata.island_flags != 3 { + continue; + } + for &mesh_index in &candidates { + let mesh = &metadata.meshes[mesh_index]; + if mesh.pool != pool + || !(matching == -1 || mesh.matching_group == -1 || matching == mesh.matching_group) + || !bounds.overlaps(mesh.local_bounds) + { + continue; + } + //Request rejection mask0 deliberately accepts every rejection_flags + //value; null exclusion table accepts every authored packed code. + //BoardWorld validates identity transforms and world-space vertices. + let first = triangle_candidates + .partition_point(|&(index, _)| index < mesh.triangle_range.start); + let last = + triangle_candidates.partition_point(|&(index, _)| index < mesh.triangle_range.end); + for &(triangle_index, triangle) in &triangle_candidates[first..last] { + let vertices = triangle.triangle.vertices; + let triangle_bounds = + Bounds::from_points(vertices).ok_or("Non-finite authored grind triangle")?; + //82ACA170 decoder's per-volume AABB gate; no leaf tolerance pad. + if !bounds.overlaps(triangle_bounds) { + continue; + } + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + //827719B8 explicitly passes zero TRIANGLE fatness, irrespective + //of the contact triangle's stored fatness. Not BoardWorld.query. + if !triangle_segment(&mut hit, start, delta, vertices, probe.radius, 0.) { + continue; + } + let fraction = clamp_fraction(hit.fraction); + if fraction < nearest { + nearest = fraction; + output = Some(ProbeHit { + fraction, + position: packed(hit.position), + //Decoded volume+16, NOT the rounded leaf's contact normal. + normal: packed(face(vertices)), + packed_surface: u32::from(metadata.packed_surfaces[triangle_index]), + }); + } + } + } + } + Ok(output) +} + +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn packed(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.] +} +fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +//827719B8's ordered fsel choices, including the unordered upper choice. +fn clamp_fraction(fraction: f32) -> f32 { + let lower = if -fraction >= 0. { 0. } else { fraction }; + if 1. - lower >= 0. { lower } else { 1. } +} + +//82ACA4E0..550, from decoded authored winding, with two rsqrt refinements. +//As in current core native_arithmetic, the PC seed is sqrt().recip(); this +//preserves host arithmetic policy, not bit-exact Xenon estimate emulation. +fn face([a, b, c]: [Vector3; 3]) -> Vector3 { + let u = sub(b, a); + let v = sub(c, a); + let n = Vector3::new( + (-u.z).mul_add(v.y, u.y * v.z), + (-u.x).mul_add(v.z, u.z * v.x), + (-u.y).mul_add(v.x, u.x * v.y), + ); + let q = n.z.mul_add(n.z, n.y.mul_add(n.y, n.x * n.x)); + let mut r = q.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.), r); + } + Vector3::new(n.x * r, n.y * r, n.z * r) +} diff --git a/crates/skate-game/src/physics/player_input/grind/world_tests.rs b/crates/skate-game/src/physics/player_input/grind/world_tests.rs new file mode 100644 index 00000000..f90a2a2c --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind/world_tests.rs @@ -0,0 +1,306 @@ +use super::*; +use skate_core::physics::{ + board_world::{ + WorldTriangle, + query_metadata::{QueryMesh, QueryMetadata}, + }, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, +}; + +fn triangle(height: f32, fatness: f32) -> WorldTriangle { + WorldTriangle::from_vertices( + [ + Vector3::new(-2., height, -2.), + Vector3::new(-2., height, 2.), + Vector3::new(2., height, -2.), + ], + RetailContactMaterial { + static_friction: 0.7, + dynamic_friction: 0.4, + restitution: 0.2, + }, + 0xdead_beef, + 0xe0, + [1.; 3], + fatness, + ) + .unwrap() +} + +fn world(entries: &[(f32, QueryPool, i32, u16)], flags: u32, fatness: f32) -> BoardWorld { + let triangles: Vec<_> = entries.iter().map(|e| triangle(e.0, fatness)).collect(); + let meshes = entries + .iter() + .enumerate() + .map(|(index, e)| QueryMesh { + triangle_range: index..index + 1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(triangles[index].triangle.vertices).unwrap(), + matching_group: e.2, + rejection_flags: u32::MAX, + geometry: 700, + pool: e.1, + }) + .collect(); + BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces: entries.iter().map(|e| e.3).collect(), + meshes, + static_edges: vec![], + island_flags: flags, + }, + ) + .unwrap() +} + +fn down() -> Probe { + Probe { + start: [-0.5, 1., -0.5, 0.], + end: [-0.5, -1., -0.5, 0.], + radius: 0., + } +} + +#[test] +fn exact_packed_surface_not_render_tag_and_zero_rejection_mask() { + let w = world(&[(0., QueryPool::Ground, 7, 0xf3a9)], 0, 0.); + let hit = surface_probe(&w, [700, 7], 0, down()).unwrap().unwrap(); + assert_eq!(hit.packed_surface, 0xf3a9); + assert_eq!(hit.fraction, 0.5); + assert_eq!(hit.normal, [0., 1., 0., 0.]); + //Actor2948 equals mesh geometry, but neither participates in this filter. + assert!(surface_probe(&w, [7, 8], 0, down()).unwrap().is_none()); + assert!( + surface_probe(&w, [0, u32::MAX], 0, down()) + .unwrap() + .is_some() + ); + let wildcard = world(&[(0., QueryPool::Ground, -1, 5)], 0, 0.); + assert!( + surface_probe(&wildcard, [0, 999], 0, down()) + .unwrap() + .is_some() + ); +} + +#[test] +fn pool_order_precedes_mesh_order_and_nearest_still_wins() { + let w = world( + &[ + (0., QueryPool::Island, -1, 1), + (0., QueryPool::Ground, -1, 2), + (0., QueryPool::Ground, -1, 3), + ], + 0, + 0., + ); + assert_eq!( + surface_probe(&w, [0; 2], 0, down()) + .unwrap() + .unwrap() + .packed_surface, + 2 + ); + let w = world( + &[ + (0., QueryPool::Ground, -1, 2), + (0.5, QueryPool::Island, -1, 1), + ], + 0, + 0., + ); + assert_eq!( + surface_probe(&w, [0; 2], 0, down()) + .unwrap() + .unwrap() + .packed_surface, + 1 + ); +} + +#[test] +fn conditional_pool_requires_exact_island_state_three() { + for flags in [0, 1, 2, 3, 4, 7] { + let w = world(&[(0., QueryPool::Conditional, -1, 1)], flags, 0.); + assert_eq!( + surface_probe(&w, [0; 2], 0, down()).unwrap().is_some(), + flags == 3 + ); + } +} + +#[test] +fn contact_fatness_does_not_move_the_query_hit() { + let w = world(&[(0., QueryPool::Ground, -1, 1)], 0, 0.5); + let hit = surface_probe(&w, [0; 2], 0, down()).unwrap().unwrap(); + assert_eq!(hit.fraction, 0.5); + assert_eq!(hit.position[1], 0.); +} + +#[test] +fn rounded_edge_hit_publishes_authored_face_not_sweep_normal() { + let w = world(&[(0., QueryPool::Ground, -1, 1)], 0, 0.); + let probe = Probe { + start: [-2.0005, 0.01, -1., 0.], + end: [-2.0005, -0.01, -1., 0.], + radius: 0.001, + }; + let mut leaf = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + assert!(triangle_segment( + &mut leaf, + vector(probe.start), + sub(vector(probe.end), vector(probe.start)), + w.triangles()[0].triangle.vertices, + probe.radius, + 0. + )); + assert!(leaf.normal.x.abs() > 0.1); + let hit = surface_probe(&w, [0; 2], 5, probe).unwrap().unwrap(); + assert_eq!(hit.normal, [0., 1., 0., 0.]); + assert_eq!(hit.position, packed(leaf.position)); +} + +#[test] +fn exact_outer_bounds_reject_leaf_endpoint_tolerance() { + let w = world(&[(0., QueryPool::Ground, -1, 1)], 0, 0.); + let probe = Probe { + end: [-0.5, 0.000001, -0.5, 0.], + ..down() + }; + let mut leaf = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + assert!(triangle_segment( + &mut leaf, + vector(probe.start), + sub(vector(probe.end), vector(probe.start)), + w.triangles()[0].triangle.vertices, + 0., + 0. + )); + assert!(surface_probe(&w, [0; 2], 0, probe).unwrap().is_none()); +} + +#[test] +fn native_short_line_gate_and_force_exit_share_matching_contract() { + let w = world(&[(0., QueryPool::Ground, 9, 1)], 0, 0.); + let half = f32::from_bits(0x3780_0000) * 0.5; + let probe = Probe { + start: [-0.5, half, -0.5, 0.], + end: [-0.5, -half, -0.5, 0.], + radius: 0.001, + }; + assert!(surface_probe(&w, [0, 9], 5, probe).unwrap().is_none()); + let p = down(); + let line = ForceExitProbe { + start: p.start, + end: p.end, + }; + assert!(force_exit_line(&w, [9, 8], line).unwrap().is_none()); + assert_eq!( + force_exit_line(&w, [0, 9], line).unwrap().unwrap().normal, + [0., 1., 0., 0.] + ); +} + +#[test] +fn triangle_bounds_gate_is_required_even_inside_a_larger_mesh() { + let triangles = vec![triangle(0., 0.), triangle(2., 0.)]; + let local_bounds = + Bounds::from_points(triangles.iter().flat_map(|t| t.triangle.vertices)).unwrap(); + let w = BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces: vec![1, 2], + meshes: vec![QueryMesh { + triangle_range: 0..2, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds, + matching_group: -1, + rejection_flags: 0, + geometry: 0, + pool: QueryPool::Ground, + }], + static_edges: vec![], + island_flags: 0, + }, + ) + .unwrap(); + let probe = Probe { + end: [-0.5, 0.000001, -0.5, 0.], + ..down() + }; + assert!(surface_probe(&w, [0; 2], 0, probe).unwrap().is_none()); +} + +#[test] +fn both_side_collector_does_not_replace_one_sided_thin_leaf() { + let w = world(&[(0., QueryPool::Ground, -1, 1)], 0, 0.); + let d = down(); + assert!( + surface_probe( + &w, + [0; 2], + 0, + Probe { + start: d.end, + end: d.start, + radius: 0. + } + ) + .unwrap() + .is_none() + ); +} + +#[test] +fn missing_metadata_and_invalid_descriptors_are_errors_not_misses() { + assert!(surface_probe(&BoardWorld::new(vec![]), [0; 2], 0, down()).is_err()); + let w = world(&[], 0, 0.); + assert!(surface_probe(&w, [0; 2], 7, down()).is_err()); + assert!( + surface_probe( + &w, + [0; 2], + 0, + Probe { + radius: -0.1, + ..down() + } + ) + .is_err() + ); + assert!( + surface_probe( + &w, + [0; 2], + 0, + Probe { + start: [f32::NAN; 4], + ..down() + } + ) + .is_err() + ); + assert!(surface_probe(&w, [0; 2], 0, down()).unwrap().is_none()); +} + +#[test] +fn outer_fsel_clamps_fraction_without_reconstructing_position() { + assert_eq!(clamp_fraction(-0.000001), 0.); + assert_eq!(clamp_fraction(1.000001), 1.); + assert_eq!(clamp_fraction(f32::NAN), 1.); + assert_eq!(clamp_fraction(0.25), 0.25); +} diff --git a/crates/skate-game/src/physics/player_input/grind_output.rs b/crates/skate-game/src/physics/player_input/grind_output.rs new file mode 100644 index 00000000..11eb9002 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind_output.rs @@ -0,0 +1,28 @@ +//! Completed common/Skeleton output needed by the stock grind graph. +use super::PlayerInputRuntime; +use skate_core::{ + air::trajectory::TrajectorySelector, physics::skeleton_body::SkeletonPhysicalRecord, +}; + +impl PlayerInputRuntime { + /// After output reset and final physical-record correction, before the + /// conditioner/graphs. Board publication separately supplies Ground80 and + /// the host-stored Motion273 flag. The selector remains the sole Air443 owner. + pub(crate) fn publish_grind_graph_outputs( + &mut self, + record: &SkeletonPhysicalRecord, + reckoning_up: [f32; 4], + selector: &TrajectorySelector, + ) -> Result<(), String> { + let toolkit = self + .toolkit + .as_ref() + .ok_or("Grind output requires the completed physical input toolkit")?; + // Common ProcessOutput82DB703C copies selector9653 unconditionally. + self.physical.air.flag_443 = u8::from(selector.grind_locked_to_middle()); + self.physical + .skeleton + .publish_twist(record, toolkit.forward, reckoning_up); + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/player_input/mod.rs b/crates/skate-game/src/physics/player_input/mod.rs index 48c03051..7c2ede70 100644 --- a/crates/skate-game/src/physics/player_input/mod.rs +++ b/crates/skate-game/src/physics/player_input/mod.rs @@ -1,6 +1,7 @@ //! Concrete original Skate3 player input phase for the authored riding world. mod dynamic_normal; -mod grind; +pub(crate) mod grind; +mod grind_output; mod ground_position; mod initial; mod output_reset; @@ -8,7 +9,6 @@ mod pre_input; mod reset; mod services; use super::{ground_runtime::GroundRuntime, riding_outputs::RidingOutputs}; -pub(crate) use grind::NoGrindEdges; pub(crate) use output_reset::reset_outputs; pub(crate) use services::{InputHostFrame, PlayerInputCallbacks}; use skate_core::{ @@ -39,10 +39,23 @@ pub(crate) struct PlayerInputRuntime { pub pre_input: pre_input::PreInputManager, pub grind: grind::GrindInputState, pending_teleport: Option, - world_edges: NoGrindEdges, + pub pending_grind: Option, + pub grind_observation: Option, } impl PlayerInputRuntime { - pub fn load(data: &Collections, world_edges: NoGrindEdges) -> Result { + pub fn pending_teleport(&self) -> Option { + self.pending_teleport + } + + pub fn request_teleport(&mut self, target: AnimationPartTransform) -> Result<(), String> { + if self.pending_teleport.is_some() { + return Err("A pending teleport must complete before replacement".into()); + } + self.pending_teleport = Some(target); + Ok(()) + } + + pub fn load(data: &Collections) -> Result { let mut physical = PhysicalPlayerInput::default(); reset_outputs(&mut physical); let mut processed = ProcessedPhysicsInput::default(); @@ -57,30 +70,18 @@ impl PlayerInputRuntime { pre_input: pre_input::PreInputManager::new(), grind: grind::GrindInputState::load(data)?, pending_teleport: None, - world_edges, + pending_grind: None, + grind_observation: None, }) } - pub fn request_teleport(&mut self, target: AnimationPartTransform) -> Result<(), String> { - if self.pending_teleport.is_some() { - return Err("A pending teleport must complete before replacement".into()); - } - self.pending_teleport = Some(target); - Ok(()) - } - pub fn pending_teleport(&self) -> Option { - self.pending_teleport - } - pub fn processed_snapshot(&self, tick: u64) -> skate_core::player::input_phase::ProcessedPhysicsSnapshot { + pub fn processed_snapshot( + &self, + tick: u64, + ) -> skate_core::player::input_phase::ProcessedPhysicsSnapshot { skate_core::player::input_phase::ProcessedPhysicsSnapshot::new(tick, self.processed) } ///Run after the current board/contact solve, before ProcessOutput publishes. - pub fn update_dynamic_normal( - &mut self, - _board: &BoardRuntime, - riding: &RidingOutputs, - gravity: Vector3, - _dt: f32, - ) { + pub fn update_dynamic_normal(&mut self, riding: &RidingOutputs, gravity: Vector3) { self.dynamic_normal.update( &riding.ground, gravity, @@ -106,6 +107,10 @@ impl PlayerInputRuntime { processed_flags_2476: self.processed.flags_2476, }, ); + // Board Fill82C03318 and common ProcessOutput82DB7598. + let normal = riding.ground.wheel_normal; + self.physical.ground.vector_80 = [normal.x, normal.y, normal.z, 0.0].map(f32::to_bits); + self.physical.ground.flag_273 = u8::from(self.processed.flags_2468 & 0x0010_0000 != 0); Ok(()) } pub fn process_stage( @@ -117,6 +122,8 @@ impl PlayerInputRuntime { host: InputHostFrame, callbacks: &mut C, stage: InputStage, + world: &skate_core::physics::board_world::BoardWorld, + world_edges: &crate::grind_world::StaticProvider, ) -> Result, String> { let mut service = services::Services { board, @@ -126,19 +133,30 @@ impl PlayerInputRuntime { callbacks, pre_input: &mut self.pre_input, grind: &mut self.grind, - world_edges: self.world_edges, + world, + world_edges, + pending_grind: &mut self.pending_grind, toolkit: &mut self.toolkit, pending_teleport: &mut self.pending_teleport, }; match stage { InputStage::ThroughTeleport => input_phase::start_input( - &mut self.player, &mut self.physical, packet, - &mut self.processed, &mut service, - ).map(Some), + &mut self.player, + &mut self.physical, + packet, + &mut self.processed, + &mut service, + ) + .map(Some), InputStage::AfterTeleport(continuation) => input_phase::finish_input( - continuation, &mut self.player, &mut self.physical, packet, - &mut self.processed, &mut service, - ).map(|()| None), + continuation, + &mut self.player, + &mut self.physical, + packet, + &mut self.processed, + &mut service, + ) + .map(|()| None), } .map_err(|e| format!("Player input: {e:?}")) } @@ -157,11 +175,10 @@ mod tests { std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), ); let data = Collections::load(&assets).unwrap(); - let state = PlayerInputRuntime::load(&data, NoGrindEdges).unwrap(); + let state = PlayerInputRuntime::load(&data).unwrap(); assert_eq!(state.player.flags_1296, 0xe00c0000); assert!(state.pending_teleport().is_none()); assert_eq!(state.physical.ground.scalar_276, -1.0); assert_eq!(state.dynamic_normal.normal, Vector3::new(0.0, 1.0, 0.0)); } } - diff --git a/crates/skate-game/src/physics/player_input/output_reset.rs b/crates/skate-game/src/physics/player_input/output_reset.rs index 03839b49..3f08b40e 100644 --- a/crates/skate-game/src/physics/player_input/output_reset.rs +++ b/crates/skate-game/src/physics/player_input/output_reset.rs @@ -15,7 +15,7 @@ pub(crate) fn reset_outputs(out: &mut PhysicalPlayerInput) { //82DE2F70..78 loads original world-up into144.82DE304C //splats-1 into the final four-word trajectory descriptor lane. landing_normal_144: UP, - selected_trajectory_240: [[0;4],[0;4],[0;4],[0xbf80_0000;4]], + selected_trajectory_240: [[0; 4], [0; 4], [0; 4], [0xbf80_0000; 4]], ..Default::default() }, reckoning: SystemReckoningFields { @@ -24,6 +24,7 @@ pub(crate) fn reset_outputs(out: &mut PhysicalPlayerInput) { }, //82DE3CC8 ground: GroundOutputFields { vector_64: UP, + vector_80: UP, vector_96: UP, scalar_276: -1., ..Default::default() diff --git a/crates/skate-game/src/physics/player_input/pre_input.rs b/crates/skate-game/src/physics/player_input/pre_input.rs index b19fd387..6319f39f 100644 --- a/crates/skate-game/src/physics/player_input/pre_input.rs +++ b/crates/skate-game/src/physics/player_input/pre_input.rs @@ -2,6 +2,7 @@ //! geometry is explicit; this owner never treats an unimplemented query as a miss. use skate_core::physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}; #[derive(Clone, Debug)] +#[allow(dead_code)] pub(crate) struct PreInputResult { pub position: [f32; 4], pub normal: [f32; 4], diff --git a/crates/skate-game/src/physics/player_input/reset.rs b/crates/skate-game/src/physics/player_input/reset.rs index 4a31c634..2e754eeb 100644 --- a/crates/skate-game/src/physics/player_input/reset.rs +++ b/crates/skate-game/src/physics/player_input/reset.rs @@ -12,8 +12,6 @@ pub(crate) fn reset_processed(input: &mut ProcessedPhysicsInput) { [0, 0, 1.0f32.to_bits(), 0], [0; 4], ], - grind_position_1120: input.grind_position_1120, - grind_direction_1136: input.grind_direction_1136, vector_1520: input.vector_1520, matrix_1536: input.matrix_1536, byte_1600: input.byte_1600, diff --git a/crates/skate-game/src/physics/player_input/services.rs b/crates/skate-game/src/physics/player_input/services.rs index 967c7b46..679d2185 100644 --- a/crates/skate-game/src/physics/player_input/services.rs +++ b/crates/skate-game/src/physics/player_input/services.rs @@ -2,7 +2,7 @@ //! preparation is performed here; the two callbacks address the shared //! skeleton/whole-player owners that cannot be borrowed twice by the host. use super::{ - grind::{GrindInputState, NoGrindEdges}, + grind::{GrindInputState, Pending}, pre_input::PreInputManager, }; use crate::physics::ground_runtime::GroundRuntime; @@ -31,6 +31,15 @@ pub(crate) struct InputHostFrame { } pub(crate) trait PlayerInputCallbacks { + fn prepare_grind( + &mut self, + board: &mut BoardRuntime, + grind: &mut GrindInputState, + processed: &ProcessedPhysicsInput, + world: &skate_core::physics::board_world::BoardWorld, + provider: &crate::grind_world::StaticProvider, + air_counter: i32, + ) -> Result; fn process_skeleton( &mut self, board: &mut BoardRuntime, @@ -61,7 +70,9 @@ pub(super) struct Services<'a, C> { pub callbacks: &'a mut C, pub pre_input: &'a mut PreInputManager, pub grind: &'a mut GrindInputState, - pub world_edges: NoGrindEdges, + pub world_edges: &'a crate::grind_world::StaticProvider, + pub world: &'a skate_core::physics::board_world::BoardWorld, + pub pending_grind: &'a mut Option, pub toolkit: &'a mut Option, pub pending_teleport: &'a mut Option, } @@ -149,7 +160,10 @@ impl InputPhaseServices for Services<'_, C> { _physical: &mut PhysicalPlayerInput, output: &mut ProcessedPhysicsInput, ) -> Result<(), String> { - *self.toolkit = Some(self.ground.prepare_toolkit(self.board, output)); + let toolkit = self.ground.prepare_toolkit(self.board, output); + //82C01744 publishes this during toolkit preparation, not PhysOut fill. + output.scalar_2616 = toolkit.absolute_speed; + *self.toolkit = Some(toolkit); Ok(()) } fn process_skeleton_82bd8918( @@ -171,7 +185,17 @@ impl InputPhaseServices for Services<'_, C> { _physical: &mut PhysicalPlayerInput, output: &mut ProcessedPhysicsInput, ) -> Result<(), String> { - self.grind - .update(self.world_edges, output, self.host.air_counter_40) + if self.pending_grind.is_some() { + return Err("Previous grind input was not consumed by PostInput".into()); + } + *self.pending_grind = Some(self.callbacks.prepare_grind( + self.board, + self.grind, + output, + self.world, + self.world_edges, + self.host.air_counter_40, + )?); + Ok(()) } } diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs index 8acaf145..80ec72c8 100644 --- a/crates/skate-game/src/physics/player_state.rs +++ b/crates/skate-game/src/physics/player_state.rs @@ -4,6 +4,7 @@ mod post_input; mod pre_state; mod publication; +mod registry; mod selection; mod transition; mod wipeout_output; @@ -19,6 +20,7 @@ use skate_core::{ use skate_data::collections::Collections; pub(crate) struct PlayerState { + pub registry: registry::StateRegistry, pub lifecycle: PhysicalPlayerStateLifecycle, pub selector: StateSelector, pub requested_state: PhysicalStateId, @@ -44,6 +46,7 @@ impl PlayerState { }) }; Ok(Self { + registry: registry::StateRegistry::new(), //82DB3008 selects the owned Sleeping object before SetPhysicsState100. lifecycle: PhysicalPlayerStateLifecycle::new(PhysicalStateId::Sleeping), selector: StateSelector::default(), @@ -108,9 +111,9 @@ pub(crate) fn post_input_and_select( physics: &mut GamePhysics, skater: &mut SkaterRuntime, ) -> Result<(), String> { - super::grind::query(physics, skater); post_input::advance(physics, skater)?; - selection::advance(physics, skater, skater.player_input.processed_snapshot(physics.ticks)) + let processed = skater.player_input.processed_snapshot(physics.ticks); + selection::advance(physics, skater, processed) } ///The reset/board/skeleton publications precede this selected-state FillPhysOut. pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { @@ -127,35 +130,6 @@ pub(crate) fn enter_after_teleport( transition::set(physics, skater, target) } -/// Custom authored climbing completes at a validated standing surface. -pub(crate) fn resume_after_climb(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - super::offboard::ground_state::exit(physics, skater); - skater.offboard.air_prediction = None; - skater.collision_extra_displacements = [[0.; 4]; 2]; - skater.animated_skeleton.motion.velocity_world = [0.; 4]; - skater.player_input.processed.vectors_544_560_592_608[3] = [0; 4]; - if skater.player_state.current() == PhysicalStateId::BipedGround { - super::offboard::ground_state::enter(physics, skater)?; - } else { - transition::set(physics, skater, PhysicalStateId::BipedGround)?; - } - // Climbing bypassed the ground query pipeline. Complete real observations - // at the new position before the next native job can interpret no contact - // as a fall, or consume the old foot hits from below the ledge. - super::foot_ik_queries::query(&physics.world, &skater.skeleton)? - .publish(&mut skater.player_input.player); - let frame = skater.offboard.ground.frame_80; - let observation = super::offboard::contact_queries::submit_toolkit( - &physics.world, &skater.offboard.layout, - skate_core::player::offboard::contact_queries::Input { - position: frame[3], surface_right: frame[0], surface_up: frame[1], surface_forward: frame[2], - animation_right: frame[0], animation_up: frame[1], velocity: [0.;4], - }, skater.player_input.processed.actor_query_2948, - )?; - skater.offboard.contacts.submit(observation); - publish(physics, skater) -} - ///Original82DB6050 prefix; coordinator calls the selected state pre-update next. pub(crate) fn pre_state( physics: &mut GamePhysics, @@ -163,3 +137,30 @@ pub(crate) fn pre_state( ) -> Result<(), String> { pre_state::advance(physics, skater) } + +/// Custom traversal bypasses native queries; discard old work before re-entry. +pub(crate) fn resume_after_climb(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + super::biped_ground::exit(skater); + skater.offboard_air_selector.reset(); + skater.collision_extra_errors = [[0.; 4]; 2]; + skater.animated_skeleton.motion.velocity_world = [0.; 4]; + skater.player_input.processed.vectors_544_560_592_608[3] = [0; 4]; + if skater.player_state.current() == PhysicalStateId::BipedGround { + super::biped_ground::enter(physics, skater)?; + } else { + transition::set(physics, skater, PhysicalStateId::BipedGround)?; + } + super::foot_ik_queries::query(&physics.world, &skater.skeleton)? + .publish(&mut skater.player_input.player); + let frame = skater.biped_ground.ground.frame_80; + skater.offboard_contact.submit( + skate_core::player::offboard::contact_toolkit::Input { + position: frame[3], right: frame[0], up: frame[1], forward: frame[2], + animation_right: frame[0], animation_up: frame[1], velocity: [0.; 4], + }, + skater.player_input.processed.actor_query_2952 as i32, + &super::offboard::contact_toolkit::StaticScene::new(&physics.world)?, + )?; + + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/post_input.rs b/crates/skate-game/src/physics/player_state/post_input.rs index 4d1ffa8b..00d9ffa4 100644 --- a/crates/skate-game/src/physics/player_state/post_input.rs +++ b/crates/skate-game/src/physics/player_state/post_input.rs @@ -14,8 +14,6 @@ pub(crate) struct PostInputState { pub trajectory_valid: bool, pub trajectory_available: bool, pub trajectory_new_candidate: bool, - pub candidates: CandidatePublicationFields, - pub grind: EmptyEdgePost, } impl PostInputState { pub fn new() -> Self { @@ -31,17 +29,6 @@ impl PostInputState { trajectory_valid: false, trajectory_available: false, trajectory_new_candidate: false, - candidates: CandidatePublicationFields { - first_object_present_196: false, - first_pending_288: false, - second_object_present_500: false, - second_pending_592: false, - staged_word_12768: 0, - staged_valid_12772: 0, - staged_latched_12776: 0, - staged_pending_12780: false, - }, - grind: EmptyEdgePost::new(), } } } @@ -103,23 +90,46 @@ fn decrement(v: u32) -> u32 { } struct Services<'a> { heading: f32, - grind: &'a mut EmptyEdgePost, + grind: &'a mut super::super::player_input::grind::GrindInputState, + pending: Option, + grind_context: super::super::player_input::grind::PostContext, + host: super::super::grind_host::LiveHost<'a>, + result: Option, processed: &'a mut skate_core::player::input_phase::ProcessedPhysicsInput, trajectory: &'a mut super::super::air_trajectory::AirTrajectoryRuntime, trajectory_input: skate_core::air::trajectory::SelectorInput, + trajectory_board_position: [f32; 4], world: &'a skate_core::physics::board_world::BoardWorld, + grab_records: [Option; 2], error: Option, } impl PostInputServices for Services<'_> { fn update_grind_manager_82d8ab08(&mut self) { - if self.processed.category_2512 != 400 { self.grind.advance(self.processed); } + let Some(pending) = self.pending.take() else { + self.error = Some("Grind post-input requires this tick's pre-input queries".into()); + return; + }; + match self.grind.post_update( + self.processed, + self.world, + pending, + self.grind_context, + &mut self.host, + ) { + Ok(result) => self.result = Some(result), + Err(error) => self.error = Some(error), + } } fn update_trajectory_selector_82d68800(&mut self) -> u8 { //82DB56B0 runs the real selector after GrindManager. That manager //can change2476, so use its current output rather than a prior snapshot. let mut input = self.trajectory_input; input.flags_2476 = self.processed.flags_2476; - match self.trajectory.update(input, self.world) { + let grind_context = super::super::air_trajectory::GrindContext::from_processed( + self.processed, + self.trajectory_board_position, + ); + match self.trajectory.update(input, self.world, grind_context) { Ok(valid) => u8::from(valid), Err(error) => { self.error = Some(error); @@ -130,30 +140,44 @@ impl PostInputServices for Services<'_> { fn calculate_scalar_2740_82db5e10(&mut self) -> f32 { self.heading } - fn register_candidate_82762ab0(&mut self, _: CandidateRegistration) { - //82762AB0 copies a grab-spline record; the inherited trait name does - //not imply engine registration or ballistic trajectory publication. - unreachable!( - "grab-spline publication requires an authored descriptor checked before this phase" - ) + fn register_candidate_82762ab0(&mut self, registration: CandidateRegistration) { + let index = match registration { + CandidateRegistration::First1888 => 0, + CandidateRegistration::Second2176 => 1, + }; + match &self.grab_records[index] { + Some(record) => copy_grab_record_82762ab0( + &mut self.processed.grab_records_1888_2176[index], + &record.0, + ), + None => { + self.error = Some("Grab publication requested without a completed record".into()) + } + } } } pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { let trajectory_input = super::super::air_phase::selector_input(physics, skater)?; + let trajectory_board_position = skater + .player_input + .toolkit + .as_ref() + .ok_or("Post-input trajectory requires this tick's board toolkit")? + .deck[3]; let post = &mut skater.player_state.post; let p = &mut skater.player_input.processed; - if skater.ground_lifecycle.edge.is_some() { - return Err("PostInput active grind requires its actual candidate/scorer output".into()); - } - let requests = &skater.ground_lifecycle.trajectory; - if requests.primary_valid_288 - || requests.secondary_valid_592 - || post.candidates.staged_pending_12780 - { - return Err( - "PostInput grab-spline publication requires its retained authored descriptor".into(), - ); - } + //82DB573C ->82D740F8 consumes the one live owner's completed results. + let publication = skater.offboard_grab.publish(); + let mut candidates = CandidatePublicationFields { + first_object_present_196: publication.records[0].is_some(), + first_pending_288: publication.records[0].is_some(), + second_object_present_500: publication.records[1].is_some(), + second_pending_592: publication.records[1].is_some(), + staged_word_12768: publication.object.flatten().unwrap_or(p.object_2464), + staged_valid_12772: u32::from(publication.object.flatten().is_some()), + staged_latched_12776: u32::from(skater.offboard_grab.interactable_latched), + staged_pending_12780: publication.object.is_some(), + }; let mut player = PostInputPlayerFields { jump_reference_1264: post.jump_reference, flags_1296: skater.player_input.player.flags_1296, @@ -163,7 +187,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> }; let mut processed = PostInputProcessedFields { jump_reference_848: post.jump_reference, - word_2464: 0, + word_2464: p.object_2464, flags_2468: p.flags_2468, flags_2472: p.flags_2472, flags_2480: p.flags_2480, @@ -190,11 +214,28 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> ); let mut services = Services { heading, - grind: &mut post.grind, + grind: &mut skater.player_input.grind, + pending: skater.player_input.pending_grind.take(), + grind_context: super::super::player_input::grind::PostContext { + board: super::super::solve::deck_frame(&physics.board), + balance_2720: skater.animation_input.fields.balance, + translation_2796: skater.animation_input.extra.grind_translation, + stability_nudge_2800: skater.animation_input.extra.grind_stability_nudge, + up_down_2804: skater.animation_input.extra.grind_up_down, + grab_min_height_2808: skater.animation_input.extra.grind_grab_min_height, + }, + host: super::super::grind_host::LiveHost { + board: &mut physics.board, + settings: &mut physics.settings, + materials: &physics.grind_materials, + }, + result: None, processed: p, trajectory: &mut skater.trajectory, trajectory_input, + trajectory_board_position, world: &physics.world, + grab_records: publication.records, error: None, }; run_post_input( @@ -202,14 +243,40 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> player: &mut player, processed: &mut processed, phys_out: &mut output, - candidates: &mut post.candidates, + candidates: &mut candidates, }, &mut services, ); if let Some(error) = services.error.take() { return Err(error); } + let grind_result = services + .result + .take() + .ok_or("Grind post-input did not publish its result")?; + processed.flags_2468 |= services.processed.flags_2468 & 0x0004_0000; drop(services); + for reason in grind_result.wipeout_reasons { + skater.wipeout.state.request(reason, 0.0); + } + skater.grind.observe(grind_result.observation); + skater.player_input.grind_observation = Some(grind_result.observation); + let g = p.grind; + skater.ground_lifecycle.edge = (g.flags_1516 & 0x0800_0000 != 0).then(|| { + let xyz = |v: [u32; 4]| { + skate_core::math::Vector3::new( + f32::from_bits(v[0]), + f32::from_bits(v[1]), + f32::from_bits(v[2]), + ) + }; + super::super::ground_phase::GroundEdge { + flags: g.flags_1516, + point: g.vector_1232.map(f32::from_bits), + start: xyz(g.second_start_1312), + end: xyz(g.second_end_1328), + } + }); //These are observations of the actual owner, never inputs to selection. post.trajectory_pending = skater.trajectory.selector.pending(); post.trajectory_valid = skater.trajectory.selector.valid(); @@ -221,6 +288,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> p.flags_2472 = processed.flags_2472; p.flags_2480 = processed.flags_2480; p.flags_2484 = processed.flags_2484; + p.object_2464 = processed.word_2464; post.jump_reference = player.jump_reference_1264; post.jump_fix_frames = player.jump_fix_frames_1308; post.latch_frames = player.latch_frames_1320; diff --git a/crates/skate-game/src/physics/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs index 76c58600..4056b636 100644 --- a/crates/skate-game/src/physics/player_state/pre_state.rs +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -8,26 +8,27 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> player.state_count_1312 = player.state_count_1312.wrapping_add(1); //KnownAir ctor82D35130 installs82327210; vslot24 at82327228 is //the same82D34DA8. Its target-following trajectory is not this deck sample. - if !matches!( - skater.player_state.current(), - PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant + //Six grind vtables call82D2D860 directly;701 calls the82D34DA8 wrapper. + if !skater.player_state.current().is_grind() + && !matches!( + skater.player_state.current(), + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround - ) { + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + | PhysicalStateId::Nonspecific + ) + { return Err("PreState requires the selected state's actual PredictFutureOfDeck".into()); } - //BipedGround's vslot24 at82327158 calls82D2D860 directly; the riding - //wrapper82D34DA8 calls that same producer with the same state argument. let deck = physics.board.part_transforms()[BodyId::Deck.index()]; let velocity = physics.board.bodies()[BodyId::Deck.index()] .rates @@ -44,17 +45,17 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> roots.supplied_prediction = Some(prediction); //82DB60DC clears sticky3184, not this-update3185. skater.foot_ik.state.contacts.support_failed = false; - let requests = &mut skater.ground_lifecycle.trajectory; - //82D74270 is PlayerGrabSpline::Sync. Its288-byte grab-spline records are - //separate from the240-byte ballistic predictions. It clears flag6 first. - //This world's triangles have no authored grab-spline descriptors, so no - //pending line batch and no query result9840 take the actual empty branch. - requests.flags_12836 &= !0x40; - if requests.pending_request.is_some() || requests.result_valid_9840 { - return Err( - "PreState has a retained grab-spline request/result requiring82D74270 synchronization" - .into(), - ); - } + //82DB60EC calls82D74270 AFTER sticky reset and before state PreUpdate. + //Consume the canonical owner's preceding validation/query results. + let p = &skater.player_input.processed; + let context = skate_core::player::offboard::ground_query::QueryContext { + selection_flags_2948: p.actor_query_2948, + matching_id_2952: p.actor_query_2952 as i32, + }; + let scene = super::super::offboard::grab_scene::Scene::new( + &physics.world, + &physics.offboard_grab_scene, + ); + skater.offboard_grab.sync(&scene, context)?; Ok(()) } diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs index 23ef068a..695b7745 100644 --- a/crates/skate-game/src/physics/player_state/publication.rs +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -3,28 +3,38 @@ use super::*; use skate_core::{ physics::{ contact_feedback::choose_surface, - filtered_state::{FilteredStateInput}, + filtered_state::{FilteredStateInput, GrindState}, }, player::input_phase::CurrentStateFields, }; pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { let state = skater.player_state.current(); - if !matches!( - state, - PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant + super::super::offboard::board_manager::runtime::publish(physics, skater); + //82DB7218..7258 runs for every state, before the selected state's Fill. + //BipedAir may subsequently overwrite64 with its sampled trajectory velocity. + let biped = &skater.biped_ground.controller.state; + skater.player_input.physical.off_board.vector_64 = + biped.frame_output.velocity.map(f32::to_bits); + skater.player_input.physical.off_board.cadence_phase_80 = biped.cadence.phase.phase; + skater.player_input.physical.off_board.locomotion_state_84 = biped.cadence.locomotion_index; + if !state.is_grind() + && state != PhysicalStateId::Nonspecific + && !matches!( + state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround - ) { + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + ) + { return Err(format!( "FillPhysOut requires the actual {state:?} output owner" )); @@ -32,6 +42,27 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> if state == PhysicalStateId::KnownAir { super::super::known_air::fill(physics, skater)?; } + if state == PhysicalStateId::BipedAir { + super::super::biped_air::fill(skater)?; + } + if state == PhysicalStateId::LandingOnDeck { + super::super::landing_on_deck::fill(skater)?; + } + if state.is_grind() || state == PhysicalStateId::Nonspecific { + super::super::grind::fill(physics, skater)?; + } + let offboard_ground = if matches!( + state, + PhysicalStateId::BipedGround | PhysicalStateId::OffBoardPushing + ) { + let output = super::super::biped_ground::fill(skater, &skater.offboard_feet)?; + Some(super::super::biped_ground::publish_fields( + output, + &mut skater.player_input.physical, + )) + } else { + None + }; let p = &skater.player_input.processed; let toolkit = skater .player_input @@ -56,6 +87,11 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> set(59, p.flags_2468 & (1 << 18) != 0); set(60, skater.animation_input.fields.balance != 0.0); set(62, p.flags_2472 & (1 << 10) != 0); + set( + 63, + state == PhysicalStateId::LandingOnDeck + && skater.landing_on_deck.state.requests_board_flip(), + ); // State63 is set only by LandingOnDeckManager::Fill82D4E0E8..E0F4 // when byte246 && time240<.02. Ordinary Ground never invokes that Fill, // so it retains the template zero here; State65 carries Ground requests. @@ -88,6 +124,9 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> } } set(87, p.flags_2484 & (1 << 10) != 0); + if let Some(output) = &offboard_ground { + set(86, output.flag_86); + } if state == PhysicalStateId::SlideGround { set(84, skater.slide_state.state.wall_riding); } @@ -97,24 +136,8 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> physical.air.handplant_time_320=skater.handplant.phase; physical.air.flag_446=u8::from(skater.handplant.flags&0x8000_0000!=0); skater.footplant.publish(&mut physical.air); - if physics.grind.active { - physical.grinds.words_136_140 = [physics.grind.kind, 1]; - physical.grinds.flag_318 = u8::from(physics.grind.front_contact); - if physics.grind.launched { - physical.air.launched_442=1; - physical.air.launch_velocity_128=physics.grind.launch_velocity.map(f32::to_bits); - } - } - //Common Biped Fill82DB7218 precedes the selected physical state's Fill. - physical.off_board.vector_64 = skater - .offboard - .controller - .state - .frame_output - .velocity - .map(f32::to_bits); - physical.off_board.phase_80 = skater.offboard.controller.state.cadence.phase.phase; - physical.off_board.locomotion_84 = skater.offboard.controller.state.cadence.locomotion_index; + //82DB76E0..76F4 publishes this independently of selected-state304/311. + physical.off_board.flag_308 = u8::from(p.flags_2480 & (1 << 19) != 0); //Common ProcessOutput82DB7044/7048 and70C0/70C4; these precede state Fill. physical.air.flag_444 = u8::from( skater @@ -138,6 +161,10 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> ..CurrentStateFields::default() }; skater.player_state.state_flags[69 - 52] = physical.state.flag_69 != 0; + if let Some(output) = &offboard_ground { + physical.state.counter_36 = output.word_36; + physical.state.skitch_value_40 = output.word_40; + } if let Some(output) = &output { physical.state.skitch_value_40 = output.state_28.grab_spline_object_id; physical.state.signed_ground_step_84 = u8::from(output.state_28.flag_84); @@ -159,6 +186,11 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> //State Fill82D3B010 is a single byte from this retained Slide owner. physical.state.signed_ground_step_84 = u8::from(skater.slide_state.state.wall_riding); } + //ProcessOutput82DB71C4 calls SkateboardController::FillPhysOut82D76D20 + //for every selected state. The constructor82D74DD8 seeds448=0; use the + //same controller owner as state transitions and post-physics ragdoll. + //Published by board_manager::runtime::publish above from retained controller state. + //ProcessOutput82DB7130..7180 copies the HandPlantManager animation //flags1876 into Air304+20 one bit at a time, preserving the low28 bits. //The same manager word is consumed by the actual input publication. @@ -178,6 +210,8 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> core::array::from_fn(|i| physics.riding.ground.parts[i].in_contact), physics.riding.ground.collision_flags & (1 << 25) != 0, ); + super::super::grind::condition(physics, skater)?; + let physical = &mut skater.player_input.physical; let filtered = skater.player_state.filtered.update(FilteredStateInput { physics_category: state.category() as i32, physics_state: state as i32, @@ -190,11 +224,15 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> //authored empty-edge world do not publish the active air/grind fields. targeting_grind: state == PhysicalStateId::KnownAir && skater.known_air.state.targeting_grind_213, - offboard_has_landed: state == PhysicalStateId::BipedAir - && skater.offboard.air_state.remaining <= f32::from_bits(0x3c888889), + //82DE6078..60C0: state501 is Offboard or OffboardAir from output320. + offboard_has_landed: physical.off_board.flag_320 != 0, offboard_on_deck: physical.off_board.flag_315 != 0, - grind: physics.grind.output(), - last_grind_distance: physics.grind.distance, + grind: if state.is_grind() { + super::super::grind::Runtime::filtered_output(&physical.grinds)? + } else { + GrindState::default() + }, + last_grind_distance: skater.grind.last_grind_distance(), }); physical.filtered_state_0 = filtered.category as u32; skater.player_state.filtered_output = Some(filtered); @@ -207,12 +245,5 @@ pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> .teleport_state .publish_output(&mut skater.player_input.physical); } - if state == PhysicalStateId::BipedGround { - super::super::offboard::ground_state::publish(skater); - } - if state == PhysicalStateId::BipedAir { - super::super::offboard::air_state::publish(skater); - } - super::super::offboard::possession::publish(physics, skater)?; Ok(()) } diff --git a/crates/skate-game/src/physics/player_state/registry.rs b/crates/skate-game/src/physics/player_state/registry.rs new file mode 100644 index 00000000..fcbfdc6c --- /dev/null +++ b/crates/skate-game/src/physics/player_state/registry.rs @@ -0,0 +1,120 @@ +//! Coordinator-owned physical state registry. +//! +//! TU3 has one lifecycle object per PhysicalPlayer state. Keeping the +//! support table here makes selection, transition, and diagnostics agree on +//! which owners are actually connected. Task-B-owned states remain explicit +//! unsupported entries until their native adapters are integrated. + +use skate_core::player::state::PhysicalStateId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StateCapability { + pub id: PhysicalStateId, + pub supported: bool, + pub has_enter: bool, + pub has_exit: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct StateRegistry; + +impl StateRegistry { + pub(crate) const fn new() -> Self { + Self + } + + pub(crate) fn capability(self, id: PhysicalStateId) -> StateCapability { + let supported = id.is_grind() + || id == PhysicalStateId::Nonspecific + || matches!( + id, + PhysicalStateId::Sleeping + | PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + ); + StateCapability { + id, + supported, + has_enter: supported && !matches!(id, PhysicalStateId::Sleeping), + has_exit: supported, + } + } + + pub(crate) fn can_transition( + self, + current: PhysicalStateId, + requested: PhysicalStateId, + ) -> bool { + if !self.capability(current).supported || !self.capability(requested).supported { + return false; + } + if current != PhysicalStateId::Sleeping + && (current.is_grind() + || requested.is_grind() + || current == PhysicalStateId::Nonspecific + || requested == PhysicalStateId::Nonspecific) + { + return true; + } + matches!( + (current, requested), + (PhysicalStateId::Sleeping, PhysicalStateId::PhysicsGround) + | ( + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + ) + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_exposes_connected_biped_state_lifecycles() { + let registry = StateRegistry::new(); + assert!( + registry + .capability(PhysicalStateId::PhysicsGround) + .supported + ); + assert!( + registry.can_transition(PhysicalStateId::PhysicsGround, PhysicalStateId::PhysicsAir) + ); + assert!(!registry.can_transition(PhysicalStateId::Sleeping, PhysicalStateId::PhysicsAir)); + assert!(registry.capability(PhysicalStateId::BipedAir).supported); + } +} diff --git a/crates/skate-game/src/physics/player_state/selection.rs b/crates/skate-game/src/physics/player_state/selection.rs index 6733524f..8bcf7f7a 100644 --- a/crates/skate-game/src/physics/player_state/selection.rs +++ b/crates/skate-game/src/physics/player_state/selection.rs @@ -13,10 +13,9 @@ pub(super) fn advance( let p = &snapshot.input; let input = StateSelectionInput { processed: ProcessedStateInput { - //The retained contact owner publishes native kind and candidate384. - grind_type_1248: physics.grind.kind, - grind_candidate_1488: physics.grind.candidate.is_some(), - grind_investigation_flags_1516: 0, + grind_type_1248: p.grind.family_1248, + grind_candidate_1488: p.grind.valid_1488, + grind_investigation_flags_1516: p.grind.flags_1516, field_1776: p.external_physics_1616.flags as i32, flags_2468: p.flags_2468, flags_2472: p.flags_2472, diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs index ccc376b8..6ae06c58 100644 --- a/crates/skate-game/src/physics/player_state/transition.rs +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -1,5 +1,6 @@ //! Native SetPhysicsState82DB8540 publication, followed by owned Enter/Exit. use super::*; +use bevy::log::info; use skate_core::{physics::board_toolkit::BoardToolkit, player::lifecycle::*}; struct Calls; @@ -10,41 +11,47 @@ impl PhysicalStateCalls for Calls { //Host lifecycle publication is completed below before applying physical //effects. These supported Exit methods do not inspect the active binding. fn exit(&mut self, call: StateCall) { - assert!(matches!( - call.state.state, - PhysicalStateId::Sleeping - | PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant - | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround - )); + assert!( + call.state.state.is_grind() + || call.state.state == PhysicalStateId::Nonspecific + || matches!( + call.state.state, + PhysicalStateId::Sleeping + | PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + ) + ); } fn enter(&mut self, call: StateCall) { - assert!(matches!( - call.state.state, - PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant - | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround - )); + assert!( + call.state.state.is_grind() + || call.state.state == PhysicalStateId::Nonspecific + || matches!( + call.state.state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant | PhysicalStateId::RevertGround + | PhysicalStateId::KnownAir + | PhysicalStateId::BipedAir + | PhysicalStateId::BipedGround + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + | PhysicalStateId::LandingOnDeck + ) + ); } } pub(super) fn set( @@ -52,75 +59,39 @@ pub(super) fn set( skater: &mut SkaterRuntime, requested: PhysicalStateId, ) -> Result<(), String> { + // RevertGround is a transient selector owner in TU3. Its Enter/Exit + // production methods are the empty adapter and it must not replace the + // live ground solver. Keep the selected solver on PhysicsGround while + // preserving the selector's next-frame flags. + let requested = match requested { + PhysicalStateId::RevertGround => PhysicalStateId::PhysicsGround, + state => state, + }; skater.player_state.requested_state = requested; let current = skater.player_state.current(); if current == requested { return Ok(()); } crate::crash_context::requested(physics.ticks, current, requested); - //Keep the actual selector request for the coordinator; do not publish a - //state whose physical Enter/Exit owner has not been connected. - let supported = match current { - PhysicalStateId::Sleeping => requested == PhysicalStateId::PhysicsGround, - PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant - | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround => matches!( - requested, - PhysicalStateId::PhysicsGround - | PhysicalStateId::PhysicsAir - | PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide - | PhysicalStateId::Nonspecific - | PhysicalStateId::KnownAir - | PhysicalStateId::FootPlant | PhysicalStateId::Boneless | PhysicalStateId::HandPlant - | PhysicalStateId::GroundAnimation - | PhysicalStateId::SlideGround - | PhysicalStateId::RevertGround - | PhysicalStateId::WipeoutGround - | PhysicalStateId::Teleporting - | PhysicalStateId::BipedAir - | PhysicalStateId::BipedGround - ), - _ => false, - }; - if !supported { + let board_observation = + super::super::offboard::board_manager::runtime::observe(physics, skater); + let p = &mut skater.player_input.processed; + info!(current = ?current, requested = ?requested, + board_controller_state = skater.skateboard_controller.fields.state_448, + board_controller_on = skater.skateboard_controller.fields.system_on_452, + processed_state = p.state_2508, processed_category = p.category_2512, + "physical state transition"); + // Keep the actual selector request for the coordinator; do not publish a + // state whose physical Enter/Exit owner has not been connected. + if !skater + .player_state + .registry + .can_transition(current, requested) + { return Err(format!( "Physical state transition {current:?} -> {requested:?} requires its native Enter/Exit production adapter" )); } - if skater.player_input.toolkit.is_none() { - skater.player_input.toolkit = Some(BoardToolkit::from_board( - &physics.board, - skater.player_input.processed.flags_2468, - skater.player_input.processed.scalar_2612, - skater.player_input.processed.vectors_464_480_496_512_528[0].map(f32::from_bits), - [0.0, 1.0, 0.0, 0.0], - )); - } - let observation = super::super::offboard::possession::observe(physics, skater)?; - let offboard = &mut skater.offboard; - let mut controller_actions = super::super::offboard::possession::Actions { - state: &mut offboard.possession, - settings: &offboard.possession_settings, - observation: &observation, - effects: super::super::offboard::board_effects::BoardEffects { - board: &mut physics.board, - animated: &mut skater.ground_lifecycle.board_animated_290, - policy: &mut offboard.board_policy, - standard_deck_drag: offboard.standard_deck_drag, - timestep: skater.player_input.processed.timestep_2604, - }, - }; - let p = &mut skater.player_input.processed; let player = &mut skater.player_input.player; let mut data = StateChangeData { player: PlayerStateChangeFields { @@ -141,6 +112,17 @@ pub(super) fn set( }, skateboard_controller: skater.skateboard_controller.fields, }; + let mut effects = skater.board_possession_live.effects( + physics, + &mut skater.ground_lifecycle.board_animated_290, + p.timestep_2604, + ); + let mut controller_actions = super::super::offboard::board_manager::Transition::new( + &mut skater.board_possession, + &board_observation, + &mut effects, + data.skateboard_controller, + ); skater .player_state .lifecycle @@ -151,6 +133,8 @@ pub(super) fn set( &mut controller_actions, ) .map_err(|e| format!("Unknown physical state {}", e.0))?; + controller_actions.finish(&mut data.skateboard_controller); + skater.board_possession_live.publish_volumes(physics); player.state_count_1312 = data.player.word_1312; player.state_value_1336 = data.player.previous_category_latch_1336; player.state_timer_1344 = data.player.scalar_1344; @@ -163,6 +147,15 @@ pub(super) fn set( p.state_count_2564 = data.processed.word_2564; p.state_timer_2664 = data.processed.scalar_2664; skater.skateboard_controller.fields = data.skateboard_controller; + if skater.player_input.toolkit.is_none() { + skater.player_input.toolkit = Some(BoardToolkit::from_board( + &physics.board, + p.flags_2468, + p.scalar_2612, + p.vectors_464_480_496_512_528[0].map(f32::from_bits), + [0.0, 1.0, 0.0, 0.0], + )); + } //Native82DB8540 publishes Processed state/history BEFORE old Exit. The //same retained objects then receive Exit followed by the new Enter. match current { @@ -170,18 +163,22 @@ pub(super) fn set( PhysicalStateId::HandPlant => skater.handplant.reset(), PhysicalStateId::FootPlant => skater.footplant.reset(), //Exit82D4C5A8 PhysicalStateId::Boneless => {}, //empty82D4C9B4 - PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific => super::super::grind::exit(physics, skater)?, - PhysicalStateId::BipedAir => super::super::offboard::air_state::exit(physics, skater), - PhysicalStateId::BipedGround => super::super::offboard::ground_state::exit(physics, skater), PhysicalStateId::PhysicsGround => super::super::ground_exit::exit(physics, skater), PhysicalStateId::PhysicsAir => super::super::air_phase::exit(skater), PhysicalStateId::KnownAir => { super::super::known_air::exit(physics, skater, requested as u32)? } + PhysicalStateId::BipedAir => super::super::biped_air::exit(skater), + PhysicalStateId::BipedGround => super::super::biped_ground::exit(skater), + PhysicalStateId::OffBoardPushing => super::super::biped_ground::exit(skater), PhysicalStateId::GroundAnimation => super::super::ground_animation::exit(physics, skater)?, + PhysicalStateId::LandingOnDeck => super::super::landing_on_deck::exit(physics, skater)?, PhysicalStateId::SlideGround => super::super::slide_state::exit(physics, skater)?, PhysicalStateId::WipeoutGround => super::super::wipeout_states::exit(physics, skater), PhysicalStateId::Sleeping | PhysicalStateId::Teleporting => {} //82B61BB8. + state if state.is_grind() || state == PhysicalStateId::Nonspecific => { + super::super::grind::exit(physics, skater)? + } _ => unreachable!("state support checked before publication"), } match requested { @@ -189,21 +186,28 @@ pub(super) fn set( PhysicalStateId::HandPlant => super::super::handplant::enter(physics, skater), PhysicalStateId::FootPlant => super::super::footplant::ground::enter(physics, skater), PhysicalStateId::Boneless => super::super::boneless::enter(physics, skater), - PhysicalStateId::GrindBoardslide | PhysicalStateId::GrindFiftyFifty | PhysicalStateId::GrindTipslide | PhysicalStateId::GrindFiveO | PhysicalStateId::GrindBackslash | PhysicalStateId::GrindDarkslide | PhysicalStateId::Nonspecific => super::super::grind::enter(physics, skater), - PhysicalStateId::BipedAir => super::super::offboard::air_state::enter(physics, skater), - PhysicalStateId::BipedGround => { - super::super::offboard::ground_state::enter(physics, skater) - } PhysicalStateId::PhysicsGround => super::super::ground_phase::enter(physics, skater), PhysicalStateId::PhysicsAir => super::super::air_phase::enter(physics, skater), PhysicalStateId::KnownAir => super::super::known_air::enter(physics, skater), + PhysicalStateId::BipedAir => super::super::biped_air::enter(physics, skater), + PhysicalStateId::BipedGround => super::super::biped_ground::enter(physics, skater), + PhysicalStateId::OffBoardPushing => super::super::biped_ground::enter(physics, skater), PhysicalStateId::GroundAnimation => super::super::ground_animation::enter(physics, skater), + PhysicalStateId::LandingOnDeck => super::super::landing_on_deck::enter(physics, skater), PhysicalStateId::SlideGround => super::super::slide_state::enter(physics, skater), PhysicalStateId::WipeoutGround => super::super::wipeout_states::enter(physics, skater), PhysicalStateId::Teleporting => { skater.teleport_state.enter(); Ok(()) } + state if state.is_grind() || state == PhysicalStateId::Nonspecific => { + super::super::grind::enter(physics, skater) + } _ => unreachable!("state support checked before publication"), - } + }?; + // Keep the command stream alongside the native lifecycle publication. + // The direct calls above remain the owner of TU3 Enter/Exit ordering; + // this record is the coordinator-facing audit/consumer boundary. + physics.exchange.request_state(requested)?; + Ok(()) } diff --git a/crates/skate-game/src/physics/player_state/wipeout_output.rs b/crates/skate-game/src/physics/player_state/wipeout_output.rs index 0c7b5fea..e0e8a493 100644 --- a/crates/skate-game/src/physics/player_state/wipeout_output.rs +++ b/crates/skate-game/src/physics/player_state/wipeout_output.rs @@ -31,13 +31,17 @@ pub(super) fn publish(skater: &mut SkaterRuntime) { physical.state.surface_height_32 = output.surface_height_32; let flags = &mut skater.player_state.state_flags; for (offset, value) in [ - (72, output.can_leave_72), (81, output.special_surface_81), - (82, output.below_surface_82), (83, output.surface_height_valid_83), + (72, output.can_leave_72), + (81, output.special_surface_81), + (82, output.below_surface_82), + (83, output.surface_height_valid_83), ] { flags[offset - 52] = value; } // These source branches only store true, preserving earlier publication. - if output.teleport_countdown_68 { flags[68 - 52] = true; } + if output.teleport_countdown_68 { + flags[68 - 52] = true; + } if output.request_teleport_69 { flags[69 - 52] = true; physical.state.flag_69 = 1; diff --git a/crates/skate-game/src/physics/render_pose.rs b/crates/skate-game/src/physics/render_pose.rs index fbd7e945..740722c3 100644 --- a/crates/skate-game/src/physics/render_pose.rs +++ b/crates/skate-game/src/physics/render_pose.rs @@ -10,7 +10,11 @@ pub(super) fn publish( ) -> Result<(), String> { let deck = deck_frame(&physics.board); let p = &skater.player_input.processed; - let wiping_out = skater.skeleton_collision.is_ragdoll; + //82DB6DDC/6DF8 passes IsWipeout82DB9100 after state postphysics, not the + //collision controller's ragdoll mode. Both correction and IK use this result. + let wiping_out = skater.wipeout.requests_wipeout(p); + //Consume the state producer's sole Skeleton16388 owner. Blocked branches + //retain it; no per-tick copy may resurrect an already consumed request. skater.skeleton_output.correction.apply( &mut skater.skeleton, p.vectors_544_560_592_608[0].map(f32::from_bits), @@ -27,7 +31,7 @@ pub(super) fn publish( state_id: p.state_2508, category_id: p.category_2512, board_body_flag_868: physics.riding.ground.part_contact_count != 0, - wipeout: skater.wipeout.requests_wipeout(p), + wipeout: wiping_out, flags_2468: p.flags_2468, flags_2484: p.flags_2484, value_2664: p.state_timer_2664, @@ -85,8 +89,9 @@ pub(super) fn publish( &mut globals, &mut locals, )?; - // Native output changes truck/wheel locals after globals. Recompose those - // final locals for the host skin instead of displaying the stale globals. + // The renderer consumes render_pose as global matrices. Physical output + // publishes final locals for mapped bones, so rebuild the complete global + // hierarchy for every mode, including ragdoll. output::compose_hierarchy_in_place( locals.len() as i32, &skater.animation.evaluator.frames.parents, diff --git a/crates/skate-game/src/physics/respawn.rs b/crates/skate-game/src/physics/respawn.rs index 02bb3e2f..e2a8d066 100644 --- a/crates/skate-game/src/physics/respawn.rs +++ b/crates/skate-game/src/physics/respawn.rs @@ -7,11 +7,11 @@ use skate_core::{ skeleton_animation_record::AnimationPartTransform as Matrix, }, player::{ - offboard::contact_queries::Probe, respawn::{Candidate, Ground, History, Observation, Validation, surface_allowed}, }, }; use skate_data::collections::Collections; +use super::offboard::contact_queries::Probe; pub(super) struct Runtime { history: History, @@ -103,8 +103,8 @@ pub(super) fn observe(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Resu teleport_requested: physical.state.flag_69 != 0, physical_state: physical.state.state_16, state_frames: skater.player_state.state_count, - ground_suppressed: physics.grind.checkpoint_suppressed, - offboard_correction: skater.offboard.controller.state.contact.active, + ground_suppressed: processed.grind.flags_1516 & 0x0800_0000 != 0, + offboard_correction: skater.biped_ground.controller.state.contact.active, ground_category: super::ground_runtime::active_surface(&physics.riding, &physics.board) & 0xffff, //82DB6EC0 publishes the same processed foot record to both fields. @@ -161,8 +161,6 @@ impl Validation for Scene<'_> { start, end, radius, - id: 0, - reverse_id: None, }; //The only actor has matching identity0; canonical map groups remain active. let Some(hit) = contact_queries::query(self.world, probe(start, end, 0.), 0)? else { diff --git a/crates/skate-game/src/physics/riding_outputs.rs b/crates/skate-game/src/physics/riding_outputs.rs index 1faeb045..dd26e482 100644 --- a/crates/skate-game/src/physics/riding_outputs.rs +++ b/crates/skate-game/src/physics/riding_outputs.rs @@ -1,6 +1,7 @@ //! Live board/query/solver outputs used by the riding gameplay owner. //! Physical calculations stay in core; this owns stock settings and scheduling //! state. Solved contacts and line probes remain distinct native inputs. +mod ground_input; mod probes; pub(crate) use probes::BoardProbes; use skate_core::{ @@ -52,44 +53,6 @@ pub(crate) struct RidingOutputs { pending_wheel_queries: Option<[Option; 4]>, } impl RidingOutputs { - ///Skeleton82BDE060's final82D8E3E0 call, argument7=true. - pub(crate) fn update_biped_reckoning( - &mut self, - air: &mut skate_core::air::reckoning::AirState, - input: super::offboard::skeleton_ground::ReckoningUpdate, - flags_2468: u32, - body_spin: f32, - ) { - let previous_up = lanes(self.reckoning.up); - skate_core::player::offboard::ground_reckoning::update( - &mut self.reckoning, &mut self.reckoning_frames, &mut self.body_spin, air, - skate_core::player::offboard::ground_reckoning::Settings { - ground_normal_smoothing: self.orientation_settings.ground_normal_smoothing, - tilt_vs_rotation: &self.tilt_vs_rotation, - tilt_vs_slope: &self.tilt_vs_slope, - }, - skate_core::player::offboard::ground_reckoning::Input { - previous_up, - requested_up: input.up, requested_forward: input.forward, blend: input.blend, - reverse_stance: flags_2468 & 0x0010_0000 != 0, - enable_body_spin_input: true, physical_body_spin_2812: body_spin, - }, - ); - } - ///Shared Reckoning82D8E3E0, argument7=false for grind/nonspecific. - pub fn update_grind_reckoning(&mut self, air: &mut skate_core::air::reckoning::AirState, - normal: [f32;4], forward: [f32;4], flags: u32, blend: f32) { - let previous_up=lanes(self.reckoning.up); - skate_core::player::offboard::ground_reckoning::update( - &mut self.reckoning,&mut self.reckoning_frames,&mut self.body_spin,air, - skate_core::player::offboard::ground_reckoning::Settings { - ground_normal_smoothing:self.orientation_settings.ground_normal_smoothing, - tilt_vs_rotation:&self.tilt_vs_rotation,tilt_vs_slope:&self.tilt_vs_slope, - },skate_core::player::offboard::ground_reckoning::Input { - previous_up,requested_up:normal,requested_forward:forward,blend, - reverse_stance:flags&0x100000!=0,enable_body_spin_input:false,physical_body_spin_2812:0., - }); - } ///Board82C0D680 resets CollisionInfo and both probes, preserving7692. pub fn reset_for_teleport(&mut self) { let elapsed = self.ground.time_without_wheel_contact; @@ -171,13 +134,6 @@ impl RidingOutputs { }) } - ///PostInput82DB5E10 uses preceding physical outputs before state updates. - pub fn prepare_input(&mut self) { - self.heading_adjust_factor = self - .speed_settings - .calculate(self.ground.wheel_normal.y, self.motion.forward_speed); - } - ///Original82DB5E10 reads current Processed464.y and2612 after Skeleton. pub fn update_input_heading(&mut self, normal_y: f32, speed: f32) -> f32 { self.heading_adjust_factor = self.speed_settings.calculate(normal_y, speed); @@ -198,57 +154,35 @@ impl RidingOutputs { processed_flags_2468: u32, animation_balance: f32, coffin: bool, + processed: &skate_core::player::input_phase::ProcessedPhysicsInput, ) { + let observations = ground_input::GroundPacketInputs::from_processed(processed); let deck = board.part_transforms()[BodyId::Deck.index()]; - // Ordinary ground riding follows the physical deck (82D4E250). - self.update_ground_reckoning_with_heading( - board, - pose, - processed_flags_2468, - animation_balance, - coffin, - [deck.basis.columns[2][0], deck.basis.columns[2][1], deck.basis.columns[2][2], 0.0], - ); - } - - /// Animated takeoff retains the rider's heading while the deck begins its - /// authored spin. Feeding the driven deck axis back into this frame would - /// apply that spin to the whole skater again on each ground-animation tick. - pub fn update_ground_reckoning_with_heading( - &mut self, - board: &BoardRuntime, - pose: RidingPoseInputs, - processed_flags_2468: u32, - animation_balance: f32, - coffin: bool, - heading: [f32; 4], - ) { - let deck = board.part_transforms()[BodyId::Deck.index()]; + //82D4E250 overrides heading1200 from input752 (raw board Z). //The source takes the previous final frame X for the damping step. let previous_right = self.reckoning_frames.system[0]; - self.reckoning_frames.heading = heading; + self.reckoning_frames.heading = [ + deck.basis.columns[2][0], + deck.basis.columns[2][1], + deck.basis.columns[2][2], + 0.0, + ]; body_spin::update_ground(&mut self.body_spin, pose.body_spin); let effective = BoardMotionOutput::from_board( board, self.reckoning.ground_normal, processed_flags_2468, ); - // PrepareBoardToolkit82C01718..1744 selects +1 only for ordered >0. - let direction = if self.motion.forward_speed > 0.0 { - 1.0 - } else { - -1.0 - }; self.reckoning.update( &self.orientation_settings, GroundOrientationInput { com_to_deck: pose.com_to_deck, - ground_normal: self.ground.wheel_normal, - dynamic_up: self.ground.overall_normal, - speed: self.motion.speed, - wheel_contact_count: i32::from(self.ground.wheel_contact_count), + ground_normal: observations.wheel_normal, + dynamic_up: observations.dynamic_up, + speed: observations.speed, + wheel_contact_count: observations.wheel_count, animation_balance, - deck_angle_curve_input: self.motion.forward_speed * direction, + deck_angle_curve_input: observations.absolute_speed, board_up: vector(deck.basis.columns[1]), board_forward: vector(deck.basis.columns[2]), effective_board_forward: vector(effective.effective_basis.columns[2]), @@ -281,30 +215,42 @@ impl RidingOutputs { physical_body_spin: f32, balance: f32, ) { - let raw = |v:[u32;4]| {let v=v.map(f32::from_bits);Vector3::new(v[0],v[1],v[2])}; - let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); + let raw = |v: [u32; 4]| { + let v = v.map(f32::from_bits); + Vector3::new(v[0], v[1], v[2]) + }; + let xyz = |v: [f32; 4]| Vector3::new(v[0], v[1], v[2]); let previous_right = self.reckoning_frames.system[0]; self.reckoning_frames.heading = toolkit.deck[2]; self.reckoning.dynamic_up = raw(p.vectors_464_480_496_512_528[4]); body_spin::update_ground(&mut self.body_spin, physical_body_spin); - self.reckoning.update(&self.orientation_settings, GroundOrientationInput { - com_to_deck: raw(p.animation_com_to_deck_752), - ground_normal: raw(p.vectors_464_480_496_512_528[0]), - dynamic_up: raw(p.vectors_464_480_496_512_528[4]), - speed: p.scalar_2652, - wheel_contact_count: p.wheel_count_2556 as i32, - animation_balance: balance, - deck_angle_curve_input: toolkit.absolute_speed, - board_up: xyz(toolkit.deck[1]), board_forward: xyz(toolkit.deck[2]), - effective_board_forward: xyz(toolkit.effective[2]), - previous_reckoning_right: xyz(previous_right), - prevent_up_behind_board: p.flags_2476 & 0x4000_0000 != 0, - }); + self.reckoning.update( + &self.orientation_settings, + GroundOrientationInput { + com_to_deck: raw(p.animation_com_to_deck_752), + ground_normal: raw(p.vectors_464_480_496_512_528[0]), + dynamic_up: raw(p.vectors_464_480_496_512_528[4]), + speed: p.scalar_2652, + wheel_contact_count: p.wheel_count_2556 as i32, + animation_balance: balance, + deck_angle_curve_input: toolkit.absolute_speed, + board_up: xyz(toolkit.deck[1]), + board_forward: xyz(toolkit.deck[2]), + effective_board_forward: xyz(toolkit.effective[2]), + previous_reckoning_right: xyz(previous_right), + prevent_up_behind_board: p.flags_2476 & 0x4000_0000 != 0, + }, + ); let up = lanes(self.reckoning.up); - self.reckoning_frames.calculate_transform(up, lanes(self.reckoning.ground_normal)); - self.reckoning_frames.calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); - self.reckoning_frames.calculate_tilt(p.flags_2468 & 0x100000 != 0, - &self.tilt_vs_rotation, &self.tilt_vs_slope); + self.reckoning_frames + .calculate_transform(up, lanes(self.reckoning.ground_normal)); + self.reckoning_frames + .calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); + self.reckoning_frames.calculate_tilt( + p.flags_2468 & 0x100000 != 0, + &self.tilt_vs_rotation, + &self.tilt_vs_slope, + ); } /// StartSkateboardLineTests82DB6310 ->82C07788: four current wheel positions, @@ -339,7 +285,9 @@ impl RidingOutputs { ///PostInput/state work. These results are available to this frame's state ///selector, independently of the subsequent solve's contact reports. pub fn finish_wheel_queries(&mut self) -> Result<(), String> { - let hits = self.pending_wheel_queries.take() + let hits = self + .pending_wheel_queries + .take() .ok_or("EndBoard requires its submitted wheel query batch")?; self.wheel_lines.publish(hits); self.probes.publish() @@ -355,10 +303,6 @@ impl RidingOutputs { processed_flags_2468: u32, time_step: f32, ) -> Result<(), String> { - let capture = bevy::log::tracing::enabled!(target: "skate_game::riding_trace", bevy::log::tracing::Level::DEBUG); - if let Some(snapshot) = board.take_solver_diagnostics(capture) { - bevy::log::debug!(target: "skate_game::riding_trace", "RIDING_SOLVER {snapshot}"); - } self.ground.update( board.contact_reports(), &self.wheel_lines, @@ -375,7 +319,8 @@ impl RidingOutputs { .iter_mut() .zip(self.ground.wheel_angular_drag) { - //82C08634 writes inertia+36 (angular); +32 is linear drag. + // S3 82C08634 writes inertia+36; S2 82B372E0 names this + // mAngularDrag. Linear drag belongs to separate state controls. body.inertia.angular_drag = drag; } self.motion = BoardMotionOutput::from_board( diff --git a/crates/skate-game/src/physics/riding_outputs/ground_input.rs b/crates/skate-game/src/physics/riding_outputs/ground_input.rs new file mode 100644 index 00000000..12b0f2ae --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs/ground_input.rs @@ -0,0 +1,33 @@ +//! Snapshot inputs copied by original TU382D8E5C0 for the Ground job. +use skate_core::{math::Vector3, player::input_phase::ProcessedPhysicsInput}; + +pub(super) struct GroundPacketInputs { + pub wheel_normal: Vector3, + pub dynamic_up: Vector3, + pub speed: f32, + pub absolute_speed: f32, + pub wheel_count: i32, +} + +impl GroundPacketInputs { + pub fn from_processed(input: &ProcessedPhysicsInput) -> Self { + let vector = |bits: [u32; 4]| { + let value = bits.map(f32::from_bits); + Vector3::new(value[0], value[1], value[2]) + }; + Self { + //82D8E790: Processed464 -> input720. + wheel_normal: vector(input.vectors_464_480_496_512_528[0]), + //82D8E79C: Processed528 -> input736. Board112 is a retained + //acceleration-derived normal, NOT CollisionInfo's overall normal. + dynamic_up: vector(input.vectors_464_480_496_512_528[4]), + speed: input.scalar_2652, //82D8E7C0 -> input772. + absolute_speed: input.scalar_2616, //82D8E7D8 -> input780. + wheel_count: input.wheel_count_2556 as i32, //82D8E924 -> input804. + } + } +} + +#[cfg(test)] +#[path = "ground_input_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/riding_outputs/ground_input_tests.rs b/crates/skate-game/src/physics/riding_outputs/ground_input_tests.rs new file mode 100644 index 00000000..a11fa600 --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs/ground_input_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn ground_reckoning_keeps_retained_dynamic_up_separate_from_wheel_support() { + let mut packet = ProcessedPhysicsInput::default(); + packet.vectors_464_480_496_512_528[0] = [0.0_f32, 1.0, 0.0, 0.0].map(f32::to_bits); + packet.vectors_464_480_496_512_528[4] = [0.6_f32, 0.8, 0.0, 0.0].map(f32::to_bits); + packet.scalar_2652 = 7.0; + packet.scalar_2612 = -3.0; + packet.scalar_2616 = 5.0; + packet.wheel_count_2556 = 2; + let input = GroundPacketInputs::from_processed(&packet); + assert_eq!(input.wheel_normal, Vector3::new(0.0, 1.0, 0.0)); + assert_eq!(input.dynamic_up, Vector3::new(0.6, 0.8, 0.0)); + assert_eq!(input.speed, 7.0); + assert_eq!(input.absolute_speed, 5.0); + assert_eq!(input.wheel_count, 2); + + //A lost-contact packet must retain its published528, not choose UP or + //the current wheel normal merely because this frame has no support. + packet.wheel_count_2556 = 0; + let lost = GroundPacketInputs::from_processed(&packet); + assert_eq!(lost.dynamic_up, input.dynamic_up); + assert_eq!(lost.wheel_count, 0); +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 5073251c..7c735694 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -8,7 +8,7 @@ use super::{ foot_ik::FootIk, foot_physical_output::FootPhysicalOutputs, ground_runtime::{GroundRuntime, GroundSettings, GroundState}, - player_input::{NoGrindEdges, PlayerInputRuntime}, + player_input::PlayerInputRuntime, skeleton_body, skeleton_input_runtime::SkeletonInputRuntime, skeleton_output::SkeletonOutput, @@ -41,11 +41,16 @@ pub(crate) struct SkaterRuntime { pub air_state: skate_core::air::state::PhysicsAirState, pub air_settings: super::air_phase::AirSettings, pub known_air: super::known_air::KnownAir, + pub biped_air: super::biped_air::BipedAir, + pub landing_on_deck: super::landing_on_deck::Runtime, + pub landing_deck: super::offboard::landing_deck::Owner, pub ground_animation: super::ground_animation::GroundAnimationRuntime, pub ground_animation_settings: super::ground_animation::GroundAnimationSettings, pub revert_state: super::revert_state::RevertState, pub slide_state: super::slide_state::SlideState, pub trajectory: super::air_trajectory::AirTrajectoryRuntime, + pub grind_camera: super::grind_camera::GrindCamera, + pub grind: super::grind::Runtime, pub footplant: super::footplant::Footplant, pub boneless: super::boneless::Boneless, pub handplant: super::handplant::Handplant, @@ -53,7 +58,6 @@ pub(crate) struct SkaterRuntime { pub wipeout_state: super::wipeout_states::WipeoutState, pub respawn: super::respawn::Runtime, pub teleport_state: super::teleport_state::Runtime, - pub offboard: super::offboard::runtime::Runtime, pub skeleton: SkeletonBody, pub skeleton_joints: SkeletonJoints, pub skeleton_drives: SkeletonDrives, @@ -61,8 +65,8 @@ pub(crate) struct SkaterRuntime { pub collision_feedback: skate_core::physics::skeleton_body::SkeletonCollisionFeedback, pub pose_errors: skate_core::physics::skeleton_body::SkeletonPoseErrors, pub collision_pose_error: [f32; 4], - ///Skeleton16288/16304, published by the completed collision response. - pub collision_extra_displacements: [[f32; 4]; 2], + /// Skeleton16288/16304, retained by the post-solver response producer. + pub collision_extra_errors: [[f32; 4]; 2], ///Skeleton16384 is published by the completed pose-error response. Wipeout ///runs after that publication; no fabricated pre-solve measurement exists. pub collision_maximum_error: Option, @@ -74,9 +78,14 @@ pub(crate) struct SkaterRuntime { pub skeleton_input: SkeletonInputRuntime, pub ground: GroundState, pub ground_runtime: GroundRuntime, - pub ground_settings: std::sync::Arc, pub ground_profiles: super::ground_runtime::GroundProfiles, + pub ground_settings: std::sync::Arc, pub ground_lifecycle: super::ground_phase::GroundLifecycle, + pub biped_ground: super::biped_ground::Owner, + pub offboard_contact: super::offboard::contact_toolkit::Owner, + pub offboard_air_selector: super::offboard::air_selector::AirSelector, + pub offboard_feet: skate_core::player::offboard::board_possession::manager::State, + pub offboard_grab: super::biped_ground::grab_runtime::Owner, pub animation_input: AnimationInput, pub animation_feedback: AnimationFeedback, pub physical_feedback: skate_core::animation::physical_feedback::PhysicalFeedback, @@ -84,6 +93,8 @@ pub(crate) struct SkaterRuntime { pub landing_quality_settings: skate_core::animation::landing_quality::Settings, pub skeleton_output: SkeletonOutput, pub skateboard_controller: super::skateboard_controller::SkateboardController, + pub board_possession: super::offboard::board_manager::Owner, + pub board_possession_live: super::offboard::board_manager::runtime::LiveState, } impl SkaterRuntime { @@ -101,9 +112,8 @@ impl SkaterRuntime { source: Option>, ) -> Result { let data = Collections::load(asset_root)?; - let ground_profiles = super::ground_runtime::GroundProfiles::load(&data)?; - let mode_index = crate::difficulty::NATIVE_MODES.iter().position(|m| *m == mode) - .ok_or_else(|| format!("Invalid skater mode {mode}"))? as u32; + let banks = skate_data::animation_banks::AnimationBanks::load(asset_root)?; + let animation_metadata = banks.metadata()?; // The host's current character is a custom skater with no pro selector // or equipped physical hat. These are profile choices, not force values. let mut animation = match source { @@ -111,7 +121,6 @@ impl SkaterRuntime { None => SkaterAnimation::load(asset_root, &data, graphs, b"")?, }; let initial_hierarchy = animation.evaluate_initial_pose()?; - let offboard = super::offboard::runtime::Runtime::load(&data, animation.motion.animation.metadata())?; let mut animated_skeleton = AnimatedSkeleton::load(asset_root, &data, &animation.evaluator.frames, false)?; let initial_parts = map_animation_parts( @@ -193,14 +202,24 @@ impl SkaterRuntime { false, )?; let wipeout_state = super::wipeout_states::WipeoutState::load( - &data, asset_root, &animation.evaluator.frames.source_sha256, + &data, + asset_root, + &animation.evaluator.frames.source_sha256, )?; let respawn = super::respawn::Runtime::load(&data, spawn, animation.checkpoint_stance())?; + let mut trajectory = super::air_trajectory::AirTrajectoryRuntime::load(&data)?; + trajectory.bind_grind_world(std::sync::Arc::clone(&physics.grind_world)); + let mut skeleton_input = SkeletonInputRuntime::load(&data)?; + skeleton_input.drive_frames = initial_parts; + skeleton_input.extra_target_positions = [ + initial_targets.com, + initial_targets.lifted_com, + initial_targets.following_com, + ]; Ok(Self { respawn, scoring: crate::scoring_runtime::Runtime::load(&data)?, climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?, - offboard, render_pose: initial_hierarchy, pose_generation: 0, centre_of_mass_filter: Default::default(), @@ -219,19 +238,27 @@ impl SkaterRuntime { air_state: Default::default(), air_settings: super::air_phase::AirSettings::load(&data)?, known_air: super::known_air::KnownAir::load(&data)?, + biped_air: super::biped_air::BipedAir::load(&data)?, + landing_on_deck: super::landing_on_deck::Runtime::load(&data)?, + landing_deck: super::offboard::landing_deck::Owner::load(&data)?, ground_animation: Default::default(), ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?, revert_state: super::revert_state::RevertState::load(&data)?, slide_state: super::slide_state::SlideState::load(&data)?, - trajectory: super::air_trajectory::AirTrajectoryRuntime::load(&data)?, + trajectory, + grind_camera: super::grind_camera::GrindCamera::default(), + grind: super::grind::Runtime::load(&data)?, footplant: super::footplant::Footplant::load(&data)?, boneless: super::boneless::Boneless::load(&data)?, handplant: super::handplant::Handplant::load(&data)?, wipeout: super::wipeout::Wipeout::load(&data)?, wipeout_state, - teleport_state: super::teleport_state::Runtime::new(super::teleport_state::Checkpoint { - transform: spawn, on_board: true, - }), + teleport_state: super::teleport_state::Runtime::new( + super::teleport_state::Checkpoint { + transform: spawn, + on_board: true, + }, + ), skeleton, skeleton_joints, skeleton_drives, @@ -246,33 +273,35 @@ impl SkaterRuntime { ..Default::default() }, collision_pose_error: [0.0; 4], - collision_extra_displacements: [[0.0; 4]; 2], + collision_extra_errors: [[0.0; 4]; 2], collision_maximum_error: None, solved_drives: None, foot_ik, foot_physical: FootPhysicalOutputs::load(&data)?, - player_input: PlayerInputRuntime::load(&data, NoGrindEdges)?, + player_input: PlayerInputRuntime::load(&data)?, 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, - initial_targets.lifted_com, - initial_targets.following_com, - ], - ..Default::default() - }, + skeleton_input, ground: GroundState::load(&data, mode, true)?, ground_runtime: GroundRuntime::load(&data)?, - ground_settings: ground_profiles.select(mode_index, 1)?, - ground_profiles, + ground_profiles: super::ground_runtime::GroundProfiles::load(&data)?, + ground_settings: std::sync::Arc::new(GroundSettings::load(&data, mode, "smooth")?), ground_lifecycle: super::ground_phase::GroundLifecycle::new(), + biped_ground: super::biped_ground::Owner::load(&data, &animation_metadata)?, + offboard_contact: Default::default(), + offboard_air_selector: super::offboard::air_selector::AirSelector::new( + super::offboard::air_selector::Settings::load(&data)?, + ), + offboard_feet: Default::default(), + offboard_grab: Default::default(), animation_input, animation_feedback: AnimationFeedback::load(&data)?, physical_feedback: super::animation_phase::initial_feedback(), skeleton_output, skateboard_controller: super::skateboard_controller::SkateboardController::new(), + board_possession: super::offboard::board_manager::Owner::load(&data)?, + board_possession_live: super::offboard::board_manager::runtime::LiveState::load( + &data, physics, + )?, }) } } diff --git a/crates/skate-game/src/physics/skeleton_body.rs b/crates/skate-game/src/physics/skeleton_body.rs index 8424c8c4..438f4873 100644 --- a/crates/skate-game/src/physics/skeleton_body.rs +++ b/crates/skate-game/src/physics/skeleton_body.rs @@ -12,22 +12,34 @@ use skate_core::{ }; use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; use std::path::Path; -#[path = "skeleton_body_drive_settings.rs"] -mod drive_settings; #[path = "skeleton_body_collision_settings.rs"] mod collision_settings; +#[path = "skeleton_body_drive_settings.rs"] +mod drive_settings; -pub(crate) fn load_collision(asset_root: &Path, data: &Collections, bank_sha: &str, - cull_all_self_pairs: bool) -> Result { - Ok(skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal( - collision_settings::load(asset_root, data, bank_sha)?, cull_all_self_pairs)) +pub(crate) fn load_collision( + asset_root: &Path, + data: &Collections, + bank_sha: &str, + cull_all_self_pairs: bool, +) -> Result { + Ok( + skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal( + collision_settings::load(asset_root, data, bank_sha)?, + cull_all_self_pairs, + ), + ) } -pub(crate) fn load_feedback(data: &Collections, - settings: skate_core::physics::skeleton_body::SkeletonCollisionSettings) - -> Result { - Ok(skate_core::physics::skeleton_body::SkeletonCollisionFeedback::new( - collision_settings::feedback(data,settings)?)) +pub(crate) fn load_feedback( + data: &Collections, + settings: skate_core::physics::skeleton_body::SkeletonCollisionSettings, +) -> Result { + Ok( + skate_core::physics::skeleton_body::SkeletonCollisionFeedback::new( + collision_settings::feedback(data, settings)?, + ), + ) } /// Build the complete original26-part drive owner using the already-resolved diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs index a5a2b9c3..abc7dda5 100644 --- a/crates/skate-game/src/physics/skeleton_feedback.rs +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -5,7 +5,10 @@ use skate_core::physics::{ assembly::BodySnapshot, board_step::CollisionBody, contact_feedback::spy_contact_jacobians, + skeleton_animation_record::AnimationPartTransform, + skeleton_board_frames::SkeletonBoardFrames, skeleton_body::{SkeletonCollisionInput, SkeletonContactBody, SkeletonContactReport}, + skeleton_root::SkeletonRootFrames, }; pub(super) fn publish( @@ -71,20 +74,31 @@ pub(super) fn publish( .normal_response(&skater.collision_feedback, [n.x, n.y, n.z, 0.0]) }; skater.collision_pose_error = response.impulse; - skater.collision_extra_displacements = response.extra; + skater.collision_extra_errors = response.extra; skater.collision_maximum_error = Some(response.maximum_error); - skater - .animated_skeleton - .board_frames - .publish_centre_of_mass( - skater.skeleton.record.centre_of_mass, - physics.settings.step.simulation.time_step, - p.flags_2472, - ); - skater - .animated_skeleton - .board_frames - .publish_local_observations(&skater.animated_skeleton.roots, &deck); + publish_board_observations( + &mut skater.animated_skeleton.board_frames, + &skater.animated_skeleton.roots, + &deck, + skater.skeleton.record.centre_of_mass, + physics.settings.step.simulation.time_step, + p.flags_2472, + ); +} + +fn publish_board_observations( + frames: &mut SkeletonBoardFrames, + roots: &SkeletonRootFrames, + deck: &AnimationPartTransform, + physical_com: [f32; 4], + dt: f32, + flags_2472: u32, +) { + //82BD9F3C..9F70 transforms retained16144 into16208;9F90 uses the + //current deck for16240. Only later82BDA024 replaces16144 with physical + //COM10928. Contact/error processing above does not consume these locals. + frames.publish_local_observations(roots, deck); + frames.publish_centre_of_mass(physical_com, dt, flags_2472); } fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec { @@ -181,3 +195,46 @@ fn contact_body(body: Option<&BodySnapshot>) -> SkeletonContactBody { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use skate_core::physics::skeleton_animation_record::IDENTITY; + + #[test] + fn retained_com_is_localized_before_new_physical_com_publication() { + let mut roots = SkeletonRootFrames::default(); + roots.world_to_animation[3] = [-10.0, -20.0, -30.0, 0.0]; + let mut deck = IDENTITY; + deck[3] = [15.0, 26.0, 37.0, 0.0]; + let old_com = [11.0, 22.0, 33.0, 4.0]; + let new_com = [13.0, 26.0, 39.0, 8.0]; + for flags in [0, 0x400] { + let mut frames = SkeletonBoardFrames { + centre_of_mass: old_com, + ..Default::default() + }; + publish_board_observations(&mut frames, &roots, &deck, new_com, 0.5, flags); + assert_eq!(frames.local_centre_of_mass, [1.0, 2.0, 3.0, 0.0]); + assert_eq!(frames.local_board_position, [5.0, 6.0, 7.0, 0.0]); + assert_eq!(frames.centre_of_mass, new_com); + assert_eq!(frames.com_velocity, [4.0, 8.0, 12.0, 8.0]); + assert_eq!( + frames.previous_centre_of_mass, + if flags == 0 { old_com } else { new_com } + ); + + //The next observation uses the last published COM, but the new + //root and deck. The history flag must not erase this distinction. + roots.world_to_animation[3] = [-12.0, -24.0, -36.0, 0.0]; + deck[3] = [20.0, 30.0, 40.0, 0.0]; + publish_board_observations(&mut frames, &roots, &deck, old_com, 0.5, flags); + assert_eq!(frames.local_centre_of_mass, [1.0, 2.0, 3.0, 0.0]); + assert_eq!(frames.local_board_position, [8.0, 6.0, 4.0, 0.0]); + assert_eq!(frames.centre_of_mass, old_com); + assert_eq!(frames.com_velocity, [-4.0, -8.0, -12.0, -8.0]); + roots.world_to_animation[3] = [-10.0, -20.0, -30.0, 0.0]; + deck[3] = [15.0, 26.0, 37.0, 0.0]; + } + } +} diff --git a/crates/skate-game/src/physics/skeleton_grind_air.rs b/crates/skate-game/src/physics/skeleton_grind_air.rs new file mode 100644 index 00000000..3573e997 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_grind_air.rs @@ -0,0 +1,47 @@ +//!82BDCFB0 at the existing ProcessData boundary; never a physical teleport. +use super::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + grind_air::{GrindAir, Input, PoseInput, Settings}, + skeleton_animation_record::{AnimationPartTransform, map_animation_parts}, + }, + player::input_phase::ProcessedPhysicsInput, +}; +pub(super) fn update( + state: &mut GrindAir, + settings: &Settings, + processed_deck: AnimationPartTransform, + p: &ProcessedPhysicsInput, + animated: &mut AnimatedSkeleton, + globals: &[NativeMatrix], +) -> Result { + let adjustment = state.update( + Input { + active: true, + board: processed_deck, + velocity: p.vectors_400_416[0].map(f32::from_bits), + angular_velocity: p.vectors_720_784_800_816_832_864[0].map(f32::from_bits), + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + timestep: p.timestep_2604, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + }, + settings, + )?; + let Some(adjustment) = adjustment else { + return Ok(false); + }; + //ProcessData maps this frame before the native producer; do not use last pose. + let parts = map_animation_parts(globals, &animated.bone_indices, &animated.physics_frames)?; + let transform = adjustment.local_transform(PoseInput { + animation_to_world: animated.roots.animation_to_world, + world_to_animation: animated.roots.world_to_animation, + physical_forward: animated.board_frames.physical_board[2], + animation_board_position: parts[0][3], + }); + animated.board_offset.refresh_transform(transform); + Ok(true) +} diff --git a/crates/skate-game/src/physics/skeleton_input_general.rs b/crates/skate-game/src/physics/skeleton_input_general.rs index 9bba0f91..4407542f 100644 --- a/crates/skate-game/src/physics/skeleton_input_general.rs +++ b/crates/skate-game/src/physics/skeleton_input_general.rs @@ -116,6 +116,8 @@ impl SkeletonInputRuntime { owners: &mut SkeletonOwners<'_>, simulation: RetailSimulationStep, ) { + //82BE27B0 clears the same16388 that states arm and PostWipeoutCheck + //consumes. A discontinuity must not retain a detached lifecycle copy. owners.correction.pending = false; let s = &mut owners.animated; let observation = skeleton_general::reset_to_animation( diff --git a/crates/skate-game/src/physics/skeleton_input_runtime.rs b/crates/skate-game/src/physics/skeleton_input_runtime.rs index df211934..f1eac57d 100644 --- a/crates/skate-game/src/physics/skeleton_input_runtime.rs +++ b/crates/skate-game/src/physics/skeleton_input_runtime.rs @@ -3,8 +3,6 @@ //! 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; @@ -12,7 +10,7 @@ use super::{ animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, }; use skate_core::{ - animation::output::{attributes::AnimationAttribute, NativeMatrix}, + animation::output::{NativeMatrix, attributes::AnimationAttribute}, input::controller::ActionMap, physics::{ board::BodyId, @@ -79,11 +77,11 @@ 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, + pub grind_air: skate_core::physics::grind_air::GrindAir, + grind_air_settings: Option, } impl Default for SkeletonInputRuntime { @@ -104,20 +102,21 @@ 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, + grind_air: Default::default(), + grind_air_settings: None, } } } impl SkeletonInputRuntime { - pub fn load_grind_settings( - data: &skate_data::collections::Collections, - ) -> Result { - grind_settings::load(data) + pub fn load(data: &skate_data::collections::Collections) -> Result { + Ok(Self { + grind_air_settings: Some(super::grind_air_settings::load(data)?), + ..Self::default() + }) } ///Called by PlayerInputCallbacks::process_skeleton, after the actual ///BoardToolkit and line tests have been published. It does not advance @@ -125,7 +124,7 @@ impl SkeletonInputRuntime { pub fn process_data( &mut self, board: &mut BoardRuntime, - _toolkit: &BoardToolkit, + toolkit: &BoardToolkit, packet: &AnimationInputPacket<'_>, _physical: &mut PhysicalPlayerInput, p: &mut ProcessedPhysicsInput, @@ -166,13 +165,7 @@ impl SkeletonInputRuntime { //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)?; - if p.flags_2476 & 0x100 != 0 { - if p.flags_2476 & 0x10000 == 0 { - return Err( - "Landing-on-board pose requires UpdateLandingOnSkateboardAdjust82BDCC28".into(), - ); - } - } else { + if p.flags_2476 & 0x100 == 0 { //82D712E0 clears result48/49 first and always writes adjusting290. //Inactive Ground leaves retained total offset/angle untouched. self.grind_air_adjusting = false; @@ -180,43 +173,15 @@ impl SkeletonInputRuntime { 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, + .ok_or("GrindAirAdjust stock settings were not loaded")?; + self.grind_air_adjusting = super::skeleton_grind_air::update( + &mut self.grind_air, 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; - } + toolkit.deck, + p, + owners.animated, + pose.globals, + )?; } } let up = p.vectors_544_560_592_608[0].map(f32::from_bits); @@ -241,6 +206,12 @@ impl SkeletonInputRuntime { packet.publication.timestep, &mut p.flags_2468, &mut p.flags_2472, + (p.flags_2476 & 0x10100 == 0x100).then_some(( + toolkit.deck, + p.vectors_720_784_800_816_832_864[5].map(f32::from_bits), + p.flags_2480, + p.off_board_scalar_2832, + )), )?; self.drive_frames = owners.animated.record.pose; p.board_at_y_delta_2768 = owners.animated.board_at_y_delta; @@ -270,10 +241,24 @@ impl SkeletonInputRuntime { } fn publish_attribute_flags(input: &AnimationInput, p: &mut ProcessedPhysicsInput) { - p.flags_2468 = input.fields.flags2468; - p.flags_2472 = input.fields.flags2472; - p.flags_2476 = input.fields.flags2476; + // The scalar and extended attribute consumers write into the same + // ProcessedPhysicsInput flag lanes that the later physics stages read. + // Publish each lane explicitly at the native boundary: + // + // AnimationInput.fields.flags2468 -> ProcessedPhysicsInput.flags_2468 + // AnimationInput.fields.flags2472 -> ProcessedPhysicsInput.flags_2472 + // AnimationInput.fields.flags2476 -> ProcessedPhysicsInput.flags_2476 + // AnimationInput.extra.flags2480 -> ProcessedPhysicsInput.flags_2480 + // AnimationInput.fields.flags2484 -> ProcessedPhysicsInput.flags_2484 + // AnimationInput.fields.flags2488 -> ProcessedPhysicsInput.flags_2488 + // + // This is publication only. No flags are synthesized, retained, or + // filtered here; reset and attribute dispatch own those decisions. + let scalar = &input.fields; + p.flags_2468 = scalar.flags2468; + p.flags_2472 = scalar.flags2472; + p.flags_2476 = scalar.flags2476; + p.flags_2484 = scalar.flags2484; + p.flags_2488 = scalar.flags2488; p.flags_2480 = input.extra.flags2480; - p.flags_2484 = input.fields.flags2484; - p.flags_2488 = input.fields.flags2488; } diff --git a/crates/skate-game/src/physics/slide_state.rs b/crates/skate-game/src/physics/slide_state.rs index 34000190..ecacedce 100644 --- a/crates/skate-game/src/physics/slide_state.rs +++ b/crates/skate-game/src/physics/slide_state.rs @@ -62,11 +62,5 @@ pub(crate) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> R super::input_phase::update_ground(physics, skater)?; update::board(physics, skater) } -/// Fill82D3B010 writes only State84. Common output publication owns other fields. -pub(crate) fn fill(_physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { - skater.player_input.physical.state.signed_ground_step_84 = - u8::from(skater.slide_state.state.wall_riding); - Ok(()) -} // Post vtable+40 is the same82D387A8 as Ground. The shared coordinator invokes // Wipeout::check_ground with the actual complete solved Observations once. diff --git a/crates/skate-game/src/physics/slide_state/update.rs b/crates/skate-game/src/physics/slide_state/update.rs index 9f392da4..4064d99b 100644 --- a/crates/skate-game/src/physics/slide_state/update.rs +++ b/crates/skate-game/src/physics/slide_state/update.rs @@ -113,7 +113,18 @@ pub(super) fn board(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re launch.player_jumped = true; let input = super::super::air_phase::selector_input(physics, skater)?; skater.trajectory.launch(launch, input, &physics.world)?; - skater.trajectory.update(input, &physics.world)?; + let grind_context = super::super::air_trajectory::GrindContext::from_processed( + &skater.player_input.processed, + skater + .player_input + .toolkit + .as_ref() + .ok_or("Slide trajectory requires current board toolkit")? + .deck[3], + ); + skater + .trajectory + .update(input, &physics.world, grind_context)?; //82D3AB10 branches directly to the epilogue. No ordinary force tail. return Ok(()); } diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs index da3797e3..0d0e1cc2 100644 --- a/crates/skate-game/src/physics/solve.rs +++ b/crates/skate-game/src/physics/solve.rs @@ -21,27 +21,21 @@ pub(super) fn advance( skater.animated_skeleton.board_frames.com_frame, skater.animated_skeleton.board_frames.lifted_com_frame, skater.animated_skeleton.roots.animation_to_world, - skater.offboard.controller.output().position, - skater.offboard.controller.state.surface, + skater.biped_ground.controller.state.position_368, + skater.biped_ground.controller.state.surface, ))?; - let board_volumes = if skater.offboard.board_policy.volumes_enabled { - colliders::world_volumes(&physics.board, &physics.settings) - } else { - Vec::new() - }; + let mut board_volumes = colliders::world_volumes(&physics.board, &physics.settings); + board_volumes.retain(|volume| skater.board_possession_live.volume_enabled(volume.body)); let skeleton_volumes = skeleton_colliders::enabled_volumes(&skater.skeleton, &skater.skeleton_collision)?; // Each native assembly has its own query record and retention buffer. // Skeleton82BE5094 passes false to82768728: its edge threshold is -1, // whereas the board requests .999. GroundPipeline supplies the remaining // shared values. Do not let the second query overwrite the first's rows. - let board_timer = crate::performance::Scope::new("board_world_contacts"); let mut contacts = physics .world .query_primitives(&board_volumes, physics.query, physics.retention) .to_vec(); - drop(board_timer); - let skeleton_timer = crate::performance::Scope::new("skeleton_world_contacts"); let mut skeleton_query = physics.query; skeleton_query.edge_cos_bend_normal_threshold = -1.0; let mut skeleton_world_volumes = skeleton_volumes.clone(); @@ -51,7 +45,6 @@ pub(super) fn advance( skeleton_query, physics.retention, )); - drop(skeleton_timer); assembly_contacts::append( &mut contacts, &board_volumes, @@ -85,15 +78,15 @@ pub(super) fn advance( dt, ); let skeleton_drive_count = drives.rows.len(); - if skater.skateboard_controller.fields.system_on_452 { - super::offboard::hand_drives::append( - &mut drives.rows, - &skater.offboard.possession, - physics.board.bodies(), - skater.skeleton.bodies(), - dt, - ); - } + //82D74FD8: persistent hand drives share the deck and skeleton reactions. + skater.board_possession.append_drives( + physics.board.bodies()[BodyId::Deck.index()], + [skater.skeleton.bodies()[3], skater.skeleton.bodies()[7]], + BodyId::Deck.index(), + [ATTACHED_REACTION_BASE + 3, ATTACHED_REACTION_BASE + 7], + dt, + &mut drives.rows, + ); let bodies = skater .skeleton .bodies_mut() @@ -122,8 +115,8 @@ pub(super) fn advance( .publish_physical_record(deck_frame(&physics.board)); // These solved rows are consumed by the actual collision/drive feedback // phase; keep their identity and impulses after the shared solve. - //Controller drive descriptors have flags28=0: they are not Skeleton's - //spy drives and must not be paired with SkeletonDriveIdentity feedback. + // Possession drives have no skeleton spy identity. They participate in the + // same solve above, but must not enter the skeleton-only feedback batch. drives.rows.truncate(skeleton_drive_count); skater.solved_drives = Some(drives); Ok(()) diff --git a/crates/skate-game/src/physics/solve/assembly_contacts.rs b/crates/skate-game/src/physics/solve/assembly_contacts.rs index 304d2f4a..7b0f1663 100644 --- a/crates/skate-game/src/physics/solve/assembly_contacts.rs +++ b/crates/skate-game/src/physics/solve/assembly_contacts.rs @@ -16,30 +16,27 @@ pub(super) fn append( board_group: u32, collision: &SkeletonCollisionMode, ) -> Result<(), String> { - //82765EF0 initializes the single-player (Island flags3) group bitmap. - //Rows1/2/3 are culled except2/3 against0;4<->5 is always culled. - //Its later extra4..7 exclusions run only for the online/non3 branch. - if board_group > 7 || collision.assembly_group > 7 { - return Err("Skater assembly collision requires its original group-table extension".into()); - } + //8277AF90 first culls the assembly pair;8277A6C0 then culls each + //primitive pair using its part group, not Volume84's world-query group. + if board_group >= 21 + || collision.assembly_group >= 21 + || collision.parts.iter().any(|part| part.part_group >= 21) { + return Err("Skater collision group is outside the original21x21 table".into()); + } + if group_pair_allowed(board_group, collision.assembly_group) { for a in board { for b in rider { let CollisionBody::Attached(part) = b.body else { unreachable!() }; - // The per-part group is independently assigned in Biped mode: - // hands5, legs17, torso18 and the two controller capsules20. - // Using assembly6 here makes the carried board collide with - // its own hand drive and the capsules that feed root correction. + //82DC3BD0/82DC4260 copy Part92 into VolumeData212 + //(assembly96 + volume212 = assembly308). Skate2's + //82E0B690 names this producer part.m_userData. Biped assigns + //hands5, legs17, torso18 and controller capsules20. if !group_pair_allowed(board_group, collision.parts[part].part_group) { continue; } - //Serialized volume+212 is source Volume+84 group. - //Stock board child constructors leave that field zero. - if !group_pair_allowed(0, collision.parts[part].volume_group) { - continue; - } append_pair(contacts, a, b); } } @@ -64,17 +61,22 @@ pub(super) fn append( } fn group_pair_allowed(a: u32, b: u32) -> bool { - // Single-player21x21 bitmap constructed by82765EF0 through the - // Island.flags==3 branch. One allowed bit per column, inverted from the - // native culling bits. Keep special Biped groups instead of truncating to8. + //82765EF0..827679AC, Island.flags==3: inverted native culling bits. + //Skate2 827B8C10 has19 groups and different pairs; do not import it. const ALLOWED: [u32; 21] = [ - 0x1876fd, 0, 0x120001, 1, 0x79d1, 0x1079e1, 0x1079f1, - 0x279f1, 0x1676f0, 0x7101, 0x5101, 0x1008f0, 0x1d77f1, - 0x233f1, 0xa57f1, 0, 0x1000, 0x6184, 0x1100, 0x5001, 0x1965, + 0x1876fd, 0, 0x120001, 1, 0x79d1, 0x1079e1, 0x1079f1, 0x279f1, 0x1676f0, 0x7101, 0x5101, + 0x1008f0, 0x1d77f1, 0x233f1, 0xa57f1, 0, 0x1000, 0x6184, 0x1100, 0x5001, 0x1965, ]; - b < 21 && ALLOWED.get(a as usize).is_some_and(|row| row & (1 << b) != 0) + b < 21 + && ALLOWED + .get(a as usize) + .is_some_and(|row| row & (1 << b) != 0) } +#[cfg(test)] +#[path = "assembly_contacts_tests.rs"] +mod tests; + pub(super) fn append_pair(contacts: &mut Vec, a: &BoardWorldVolume, b: &BoardWorldVolume) { //8277B0F0 and8277B2C0 enter the same8277A508 primitive walker with //identical padding/triangle settings. Keep A/B orientation and point order. diff --git a/crates/skate-game/src/physics/solve/assembly_contacts_tests.rs b/crates/skate-game/src/physics/solve/assembly_contacts_tests.rs new file mode 100644 index 00000000..b8200404 --- /dev/null +++ b/crates/skate-game/src/physics/solve/assembly_contacts_tests.rs @@ -0,0 +1,104 @@ +use super::*; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, collision::Sphere, contact::RetailContactMaterial, + skeleton_body::SkeletonCollisionSettings, world_contact::ContactPrimitive, + }, +}; + +fn mode() -> SkeletonCollisionMode { + let mut mode = SkeletonCollisionMode::new_normal( + SkeletonCollisionSettings { + enabled: true, + normal_material: material(), + compliant: [false; 24], + priority: [0.0; 24], + effect_time: 0.0, + }, + true, + ); + mode.select_driven(3).unwrap(); + mode +} +fn material() -> RetailContactMaterial { + RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.3, + restitution: 0.0, + } +} +fn volume(body: CollisionBody, x: f32) -> BoardWorldVolume { + BoardWorldVolume { + body, + primitive: ContactPrimitive::Sphere(Sphere { + center: Vector3::new(x, 0.0, 0.0), + radius: 0.5, + }), + linear_velocity: Vector3::ZERO, + material: material(), + } +} + +#[test] +fn board_pairs_use_actual_biped_part_groups() { + let mode = mode(); + assert_eq!(mode.assembly_group, 6); + let board = [volume(CollisionBody::Board(BodyId::Deck), 0.0)]; + // The same overlapping geometry must not collide merely because its + // containing assembly is6. Carried board4 excludes all these part groups. + for part in [3, 7, 15, 19, 16, 20, 23, 24, 25] { + let rider = [volume(CollisionBody::Attached(part), 0.25)]; + let mut contacts = Vec::new(); + append(&mut contacts, &board, &rider, 4, &mode).unwrap(); + assert!(contacts.is_empty(), "carried board contacted part{part}"); + } + // Released board7 can contact the original leg group17, but not torso18 + // or controller20. Do not disable all board/rider collision as a fix. + for (part, expected) in [(15, true), (19, true), (23, false), (24, false)] { + let mut contacts = Vec::new(); + append( + &mut contacts, + &board, + &[volume(CollisionBody::Attached(part), 0.25)], + 7, + &mode, + ) + .unwrap(); + assert_eq!(!contacts.is_empty(), expected, "released board part{part}"); + } +} + +#[test] +fn assembly_gate_and_world_query_group_are_distinct() { + let mut mode = mode(); + let board = [volume(CollisionBody::Board(BodyId::Deck), 0.0)]; + let rider = [volume(CollisionBody::Attached(15), 0.25)]; + mode.parts[15].volume_group = 1; // World-query group is not VolumeData212. + let mut contacts = Vec::new(); + append(&mut contacts, &board, &rider, 7, &mode).unwrap(); + assert!(!contacts.is_empty()); + contacts.clear(); + mode.assembly_group = 1; + append(&mut contacts, &board, &rider, 7, &mode).unwrap(); + assert!(contacts.is_empty()); +} + +#[test] +fn original_single_player_table_keeps_special_groups_and_symmetry() { + for a in 0..21 { + for b in 0..21 { + assert_eq!(group_pair_allowed(a, b), group_pair_allowed(b, a)); + } + } + assert!(!group_pair_allowed(4, 5)); + assert!(!group_pair_allowed(4, 17)); + assert!(group_pair_allowed(7, 17)); + assert!(group_pair_allowed(5, 20)); + assert!(!group_pair_allowed(7, 20)); + assert!(group_pair_allowed(11, 11)); + assert!(!group_pair_allowed(11, 12)); + assert!(group_pair_allowed(12, 12)); + assert!(group_pair_allowed(13, 13)); + assert!(!group_pair_allowed(21, 0)); +} diff --git a/crates/skate-game/src/physics/tests/flat_ground.rs b/crates/skate-game/src/physics/tests/flat_ground.rs new file mode 100644 index 00000000..27631f6a --- /dev/null +++ b/crates/skate-game/src/physics/tests/flat_ground.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn flat_render_vertices_and_actual_queries_agree_across_entire_surface() { + let surfaces = Terrain::Flat.surfaces(); + assert_eq!(surfaces.iter().map(Vec::len).sum::(), 1); + for vertex in surfaces.iter().flatten().flatten() { + assert_eq!(vertex.y, HEIGHT); + } + // Test-only material: geometry checks do not exercise contact response. + let world = Terrain::Flat.world(RetailContactMaterial { + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 0.0, + }); + assert_eq!(world.triangles().len(), 2); + let metadata = world.query_metadata().unwrap(); + assert!(metadata.static_edges.is_empty()); + assert_eq!(metadata.packed_surfaces.len(), 2); + for entry in world.triangles() { + for vertex in entry.triangle.vertices { + assert_eq!(vertex.y, HEIGHT); + } + } + // Includes former downhill route, half-pipe, seam and near each boundary. + for x in [-49.9, -12.0, -3.0, 0.0, 3.0, 12.0, 49.9] { + for z in [-49.9, -16.0, -4.0, 0.0, 4.0, 8.0, 16.0, 49.9] { + let hit = world + .query_thin_line(Vector3::new(x, 2.0, z), Vector3::new(x, -2.0, z)) + .unwrap() + .expect("flat surface query"); + assert!((hit.geometry.position.y - HEIGHT).abs() < 1.0e-5); + assert!(hit.geometry.normal.x.abs() < 1.0e-6); + assert!(hit.geometry.normal.z.abs() < 1.0e-6); + assert!((hit.geometry.normal.y - 1.0).abs() < 1.0e-6); + } + } +} diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs index 0ae44ab3..83c0c16b 100644 --- a/crates/skate-game/src/physics/wipeout.rs +++ b/crates/skate-game/src/physics/wipeout.rs @@ -10,11 +10,18 @@ use skate_data::collections::Collections; pub(crate) struct Wipeout { pub state: Requests, settings: Settings, - offboard: skate_core::player::offboard::ground_lifecycle::CollisionSettings, - offboard_air: skate_core::player::offboard::air_collision::Settings, modes: [Mode; 5], } impl Wipeout { + pub fn check_air_collision( + &mut self, + p: &ProcessedPhysicsInput, + frame: &wipeout::Frame, + ) -> Result<(), String> { + let mode = self.mode(p)?; + wipeout::check_air_collision(&mut self.state, &self.settings, &mode, frame); + Ok(()) + } pub fn load(data: &Collections) -> Result { let (settings, modes) = settings::load(data)?; let mut state = Requests::new(); @@ -22,8 +29,6 @@ impl Wipeout { Ok(Self { state, settings, - offboard: settings::offboard(data)?, - offboard_air: settings::offboard_air(data)?, modes, }) } @@ -49,7 +54,7 @@ impl Wipeout { wipeout::check_air(&mut self.state, &self.settings, &mode, &frame, use_com); Ok(()) } - fn mode(&self, p: &ProcessedPhysicsInput) -> Result { + pub(crate) fn mode(&self, p: &ProcessedPhysicsInput) -> Result { self.modes .get(p.state_variant_index_2528 as usize) .copied() @@ -118,66 +123,12 @@ pub(super) fn check_after_physics( } Ok(()) } - PhysicalStateId::BipedAir => { - let mode = skater.wipeout.mode(&skater.player_input.processed)?; - let frame = observations.frame()?; - let p = &skater.player_input.processed; - skate_core::player::offboard::air_collision::post( - &skater.offboard.air_state, - &mut skater.wipeout.state, - &skater.wipeout.offboard_air, - &skate_core::player::offboard::air_collision::PostInput { - shared: &frame, - root_velocity: skater.skeleton_input.root_velocity, - check_squash: mode.check_squash, - air_settings: &skater.wipeout.settings.air, - elapsed: p.state_timer_2664, - flags_2484: p.flags_2484, - forward: p.effective_anim_transform_192[2].map(f32::from_bits), - right: p.effective_anim_transform_192[0].map(f32::from_bits), - right_stick: [ - skater.animation_input.extra.look_x, - skater.animation_input.extra.look_y, - ], - }, - ); - super::offboard::air_diagnostics::record(skater); - Ok(()) - } PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround | PhysicalStateId::RevertGround => { skater.wipeout.check_ground(&observations) } PhysicalStateId::GroundAnimation => { skater.wipeout.check_ground_animation(&observations, 1.0) } - PhysicalStateId::BipedGround => { - use skate_core::player::offboard::ground_lifecycle::{ - CollisionInput, PostInput, post_physics, - }; - let frame = observations.frame()?; - post_physics( - &mut skater.offboard.ground, - &mut skater.wipeout.state, - &skater.wipeout.offboard, - &PostInput { - collision: CollisionInput { - shared: &frame, - skeleton_velocity_16336: skater.skeleton_input.root_velocity, - contact_flag_4072: skater.collision_feedback.flags.group_8, - contact_force_4056: skater.collision_feedback.maximum_group_8_force, - }, - processed_flags_2484: skater.player_input.processed.flags_2484, - processed_velocity_608: skater.player_input.processed.vectors_544_560_592_608 - [3] - .map(f32::from_bits), - skeleton_displacement_16288: skater.collision_extra_displacements[0], - skeleton_displacement_16304: skater.collision_extra_displacements[1], - ground_kind_356: skater.offboard.retained_contact.kind_164, - }, - ); - super::offboard::air_diagnostics::record(skater); - Ok(()) - } PhysicalStateId::PhysicsAir => skater.wipeout.check_air(&observations, false), _ => Ok(()), //Other concrete states dispatch their own postphysics check. } diff --git a/crates/skate-game/src/physics/wipeout/observations.rs b/crates/skate-game/src/physics/wipeout/observations.rs index 7d3706e1..b529b042 100644 --- a/crates/skate-game/src/physics/wipeout/observations.rs +++ b/crates/skate-game/src/physics/wipeout/observations.rs @@ -30,7 +30,7 @@ pub(crate) struct Observations<'a> { pub grind_normal: Option<[f32; 4]>, } impl Observations<'_> { - pub(super) fn frame(&self) -> Result { + pub(crate) fn frame(&self) -> Result { let p = self.processed; let c = self.collision; let b = self.board; diff --git a/crates/skate-game/src/physics/wipeout/settings.rs b/crates/skate-game/src/physics/wipeout/settings.rs index 6b5051ed..e01cce5b 100644 --- a/crates/skate-game/src/physics/wipeout/settings.rs +++ b/crates/skate-game/src/physics/wipeout/settings.rs @@ -5,18 +5,6 @@ use skate_core::{ point_graph::PointGraph, }; use skate_data::collections::Collections; -pub(super) fn offboard_air( - data: &Collections, -) -> Result { - let f = |name| data.float("physics_wipeout", "default", name); - Ok(skate_core::player::offboard::air_collision::Settings { - minimum_speed: f("Wipeout_OB_Air_MinSpeed")?, - maximum_displacement: f("Wipeout_OB_Air_SkelMaxDisp")?, - maximum_contact: f("Wipeout_OB_Air_SkelMaxContact")?, - maximum_arm_contact: f("Wipeout_OB_SkeletonMaxContactArms")?, - maximum_squash: f("Wipeout_OB_MaxSquash")?, - }) -} pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> { let f = |name| data.float("physics_wipeout", "default", name); let graph = data @@ -89,21 +77,3 @@ pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> .map_err(|_| "Expected five wipeout physics modes".to_owned())?, )) } - -pub(super) fn offboard( - data: &Collections, -) -> Result { - let f = |name| data.float("physics_wipeout", "default", name); - Ok( - skate_core::player::offboard::ground_lifecycle::CollisionSettings { - vehicle_scalar: f("Wipeout_OB_VehicleScalar")?, - vehicle_contact: f("Wipeout_OB_VehicleContact")?, - maximum_displacement: f("Wipeout_OB_SkeletonMaxDisp")?, - maximum_arm_contact: f("Wipeout_OB_SkeletonMaxContactArms")?, - maximum_body_contact: f("Wipeout_OB_SkeletonMaxContact")?, - minimum_speed: f("Wipeout_OB_MinSpeed")?, - maximum_squash: f("Wipeout_OB_MaxSquash")?, - special_scalar: f("Hash_472174920C68FBE3")?, - }, - ) -} diff --git a/crates/skate-game/src/physics/wipeout_states/drive_settings.rs b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs index 706ad795..44d81c59 100644 --- a/crates/skate-game/src/physics/wipeout_states/drive_settings.rs +++ b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs @@ -10,7 +10,9 @@ pub(crate) fn load( ) -> Result { let skeleton = PhysicsSkeleton::load( &asset_root.join("private/stock/physics-skeletons.json"), - primary_bank_sha, "PHYS_TPOSE")?; + primary_bank_sha, + "PHYS_TPOSE", + )?; if skeleton.bones.len() != 24 { return Err("Wipeout requires the actual24 physical skeleton parts".into()); } @@ -20,14 +22,19 @@ pub(crate) fn load( let words = data.words::<9>("physics_skeleton_drives", "default", &name)?; bone[part] = std::array::from_fn(|i| f32::from_bits(words[i])); } - let roots = ["root_drive_start_scalar", "root_drive_scalar", - "root_drive_controlled_scalar", "root_drive_end_scalar"]; + let roots = [ + "root_drive_start_scalar", + "root_drive_scalar", + "root_drive_controlled_scalar", + "root_drive_end_scalar", + ]; let mut root = [0.0; 4]; for (i, name) in roots.into_iter().enumerate() { root[i] = data.float("physics_skeleton_drives", "default", name)?; } Ok(Settings { - bone, root, + bone, + root, strength: [ data.float("animation", "default", "DriveStrengthLocal")?, data.float("animation", "default", "DriveStrengthRootLocal")?, diff --git a/crates/skate-game/src/physics/wipeout_states/skeleton.rs b/crates/skate-game/src/physics/wipeout_states/skeleton.rs index c4dbd50d..fc213a78 100644 --- a/crates/skate-game/src/physics/wipeout_states/skeleton.rs +++ b/crates/skate-game/src/physics/wipeout_states/skeleton.rs @@ -5,10 +5,7 @@ use crate::physics::{ }; use skate_core::{ animation::output::NativeMatrix, - physics::{ - skeleton_animation_record::IDENTITY, - skeleton_body::SkeletonTargetInput, - }, + physics::{skeleton_animation_record::IDENTITY, skeleton_body::SkeletonTargetInput}, player::{ input_phase::ProcessedPhysicsInput, wipeout_state::{drives, skeleton}, @@ -40,54 +37,158 @@ pub(crate) fn update( processed.vectors_544_560_592_608[3].map(f32::from_bits), processed.state_timer_2664, ); + let target_input = SkeletonTargetInput { + animation_hips: &s.animation_hips, + animation_board: &s.animation_board, + animation_to_world: &s.roots.animation_to_world, + inverse_board: &s.roots.inverse_board, + skate_root: &s.board_frames.skate_root, + com_frame: &s.board_frames.com_frame, + lifted_com_frame: &s.board_frames.lifted_com_frame, + teleporting: input.teleporting, + }; + //82BDFAC8 skips the whole82BE1618 call. Its nested82BE1870 call to + //82BE18B8 is skipped too: retain all four targets and extra-body velocities. if !skip_targets { - let target = owners.drives.targets.update_positions( - SkeletonTargetInput { - animation_hips: &s.animation_hips, - animation_board: &s.animation_board, - animation_to_world: &s.roots.animation_to_world, - inverse_board: &s.roots.inverse_board, - skate_root: &s.board_frames.skate_root, - com_frame: &s.board_frames.com_frame, - lifted_com_frame: &s.board_frames.lifted_com_frame, - teleporting: input.teleporting, - }, + input.animation_board_to_physics = + owners.drives.targets.update_hook_positions(&target_input); + let positions = owners.drives.targets.update_extra_targets( owners.body, + target_input.com_frame, + target_input.lifted_com_frame, ); - input.animation_board_to_physics = target.animation_board_to_physics; - input.extra_target_positions = [ - target.positions.com, target.positions.lifted_com, - target.positions.following_com, - ]; - owners.pose_errors.set_targets(target.positions); - //82BDFA88 ignores the target continuity result: no GeneralUpdate reset. + input.extra_target_positions = + [positions.com, positions.lifted_com, positions.following_com]; + owners.pose_errors.set_targets(positions); } + //82BDFA88 ignores the target continuity result: no GeneralUpdate reset. s.board_frames.skate_root = s.roots.animation_to_world; s.board_frames.update_com_lift( - &s.roots.animation_to_world, s.board_frames.centre_of_mass, 0.0); + &s.roots.animation_to_world, + s.board_frames.centre_of_mass, + 0.0, + ); if processed.state_timer_2664 == 0.0 { let contacts = std::array::from_fn(|i| { let query = processed.line_tests_960_1008_1056[i]; (query.valid != 0).then(|| query.position.map(f32::from_bits)) }); - let result = owners.ik.update(s, globals, PhysicalInput { - state_id: processed.state_2508, - flags_2468: processed.flags_2468, - flags_2472: processed.flags_2472, - flags_2480: processed.flags_2480, - contact_bone: owners.animation_input.contacts.bone as usize, - physical_board: &s.board_frames.physical_board, - hips_world_position: owners.body.part_transforms()[23][3], - current_contacts: contacts, - })?; + let result = owners.ik.update( + s, + globals, + PhysicalInput { + state_id: processed.state_2508, + flags_2468: processed.flags_2468, + flags_2472: processed.flags_2472, + flags_2480: processed.flags_2480, + contact_bone: owners.animation_input.contacts.bone as usize, + physical_board: &s.board_frames.physical_board, + hips_world_position: owners.body.part_transforms()[23][3], + current_contacts: contacts, + }, + )?; input.drive_frames = result.frames; } - let residual = drives::update(owners.drives, &input.drive_frames, settings, + //82BDFB84 passes Skeleton+12624, not the solved physical pose at8016. + //ProcessData rewrites this animation-volume buffer each tick; first-tick + //IK may modify it above.82BEB0C8 builds its inverses from that supplied + //buffer (r4+64..), so physical parts cannot substitute for the drive target. + let residual = drives::update( + owners.drives, + &input.drive_frames, + settings, drives::Weights { - start, end, controlled, - upper_extra: if processed.flags_2484 & 0x20 != 0 { extra } else { 0.0 }, - lower_extra: if processed.flags_2484 & 0x10 != 0 { extra } else { 0.0 }, - }); + start, + end, + controlled, + upper_extra: if processed.flags_2484 & 0x20 != 0 { + extra + } else { + 0.0 + }, + lower_extra: if processed.flags_2484 & 0x10 != 0 { + extra + } else { + 0.0 + }, + }, + ); s.motion.next_trajectory = IDENTITY; Ok(residual) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[ignore = "requires private stock skater and collection assets"] + fn retained_velocity_skips_all_wipeout_target_writes() { + use crate::physics::{GamePhysics, SkaterRuntime}; + use skate_core::math::Vector3; + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + skater.player_input.processed.state_2508 = 300; + skater.player_input.processed.state_timer_2664 = 1.0; + // Distinct synthetic animation target exposes accidental substitution + // of the real physical parts; no world-space body is moved here. + skater.skeleton_input.drive_frames = [IDENTITY; 24]; + skater.skeleton_input.drive_frames[17][3][0] = 0.25; + for part in [24, 25] { + skater.skeleton.bodies_mut()[part].rates.linear_velocity = + Vector3::new(1.25, -2.5, 3.75); + } + let velocities = [24, 25].map(|part| skater.skeleton.bodies()[part].rates.linear_velocity); + let targets: [_; 4] = core::array::from_fn(|i| skater.skeleton_drives.targets.transform(i)); + let positions = skater.skeleton_input.extra_target_positions; + let board_target = skater.skeleton_input.animation_board_to_physics; + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + update( + &mut skater.skeleton_input, + &mut owners, + &skater.player_input.processed, + &skater.animation.packet.hierarchy, + &skater.wipeout_state.drives, + 0.0, + 0.0, + 0.0, + 0.0, + true, + ) + .unwrap(); + assert_eq!( + [24, 25].map(|part| owners.body.bodies()[part].rates.linear_velocity), + velocities + ); + assert_eq!( + core::array::from_fn::<_, 4, _>(|i| owners.drives.targets.transform(i)), + targets + ); + assert_eq!(skater.skeleton_input.extra_target_positions, positions); + assert_eq!( + skater.skeleton_input.animation_board_to_physics, + board_target + ); + let knee = owners.drives.bones[17].as_ref().unwrap(); + for channel in 0..2 { + if knee.active[channel] { + assert_eq!( + knee.frames[channel].body_b.translation.x, 0.25, + "UpdateRagdoll must consume the animation-volume buffer12624" + ); + } + } + } +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index 51be56b1..51c51860 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -182,6 +182,7 @@ fn retail_collision_world( ) .ok_or("Invalid RWCM cluster bounds")?; meshes.push(QueryMesh { + geometry: 0, rejection_flags: 0, triangle_range: range, local_to_world: RetailAffineTransform::IDENTITY, world_to_local: RetailAffineTransform::IDENTITY, @@ -326,6 +327,7 @@ pub(crate) fn collision_world( let metadata = QueryMetadata { packed_surfaces, meshes: vec![QueryMesh { + geometry: 0, rejection_flags: 0, triangle_range: 0..triangles.len(), local_to_world: RetailAffineTransform::IDENTITY, world_to_local: RetailAffineTransform::IDENTITY, diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index f7bae298..17d1d75a 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -233,8 +233,10 @@ impl SkaterAnimation { tick: action_output.tick, action: action_output, }); + self.motion.animation_phase = self.state.phase; self.motion_controller .update(&graphs.motion.runtime.program, dt, &mut self.motion); + self.state.phase = self.motion.animation_phase; if !self.motion.errors.is_empty() { return Err(self.motion.errors.join("\n")); } @@ -249,9 +251,6 @@ impl SkaterAnimation { } self.motion.animation.apply_parameters()?; - if let Some(phase) = self.motion.phase_write.take() { - self.state.phase = phase; - } self.motion.animation.advance(dt, self.state.phase); //Collect before pose evaluation consumes clip history;82B98980 then //uses those exact records for board/mirror/switch event publication. diff --git a/crates/skate-game/src/tests/air_playback.rs b/crates/skate-game/src/tests/air_playback.rs index 485cedcc..849c807d 100644 --- a/crates/skate-game/src/tests/air_playback.rs +++ b/crates/skate-game/src/tests/air_playback.rs @@ -40,32 +40,57 @@ fn airborne_tailwalk_from_raw_controller_reaches_stock_cycle() { roll_off_sequence(None, Some(0), [0, 32767], true); } -fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stick: [i16; 2], tailwalk: bool) { +fn roll_off_sequence( + air_stick: Option, + grab_hand: Option, + grab_stick: [i16; 2], + tailwalk: bool, +) { let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); + let mut physics = GamePhysics::load_with_terrain(root, ground::Terrain::Course).unwrap(); // Keep this air fixture's open edge now that the production level has a // downhill ramp. The dedicated ramp regression uses the unmodified world. - physics.world = BoardWorld::new(physics.world.triangles().iter().copied() - .filter(|face| !(face.tag == 1 - && face.triangle.vertices.iter().any(|vertex| vertex.z > 4.0))) - .collect()); + physics.world = BoardWorld::new( + physics + .world + .triangles() + .iter() + .copied() + .filter(|face| { + !(face.tag == 1 && face.triangle.vertices.iter().any(|vertex| vertex.z > 4.0)) + }) + .collect(), + ); if grab_hand.is_some() { // A deeper test landing gives the held cycle and OUT animation time // to run before contact can hide a failed airborne release. - physics.world = BoardWorld::new(physics.world.triangles().iter().map(|face| { - let vertices = face.triangle.vertices.map(|mut vertex| { - if vertex.y < -1.0 { vertex.y -= 18.0; } - vertex - }); - skate_core::physics::board_world::WorldTriangle::from_vertices( - vertices, face.material, face.tag, - skate_core::physics::collision::TriangleFeature::ONE_SIDED, - [1.0; 3], 0.0, - ).unwrap() - }).collect()); + physics.world = BoardWorld::new( + physics + .world + .triangles() + .iter() + .map(|face| { + let vertices = face.triangle.vertices.map(|mut vertex| { + if vertex.y < -1.0 { + vertex.y -= 18.0; + } + vertex + }); + skate_core::physics::board_world::WorldTriangle::from_vertices( + vertices, + face.material, + face.tag, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, + [1.0; 3], + 0.0, + ) + .unwrap() + }) + .collect(), + ); } let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); @@ -86,12 +111,20 @@ fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stic } } input.sample_raw_for_test(skate_core::input::xbox::XboxState { - buttons: if tick >= 12 && !saw_air { 0x1000 } - else if tailwalk && saw_air && grab_frames >= 20 { 0x2000 } - else { 0 }, + buttons: if tick >= 12 && !saw_air { + 0x1000 + } else if tailwalk && saw_air && grab_frames >= 20 { + 0x2000 + } else { + 0 + }, triggers, left: [if saw_air { air_stick.unwrap_or(0) } else { 0 }, 0], - right: if triggers != [0; 2] { grab_stick } else { [0; 2] }, + right: if triggers != [0; 2] { + grab_stick + } else { + [0; 2] + }, }); let mut actions = input.player_actions(); controls.update( @@ -133,17 +166,30 @@ fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stic if tailwalk && state.category() == 200 { let motion = &skater.animation.motion; if grab_frames >= 20 { - eprintln!("Tailwalk frame={grab_frames} LeftPush={:?} RightPush={:?} angle={:?} TailGrab={:?} NoFootAirWalk={:?} animation={:?}", - controls.action_intents.get("LeftPush"), controls.action_intents.get("RightPush"), + eprintln!( + "Tailwalk frame={grab_frames} LeftPush={:?} RightPush={:?} angle={:?} TailGrab={:?} NoFootAirWalk={:?} animation={:?}", + controls.action_intents.get("LeftPush"), + controls.action_intents.get("RightPush"), controls.action_intents.get("BoardAdjustAngle"), - motion.animation.motion_intents.get("TailGrab"), motion.animation.motion_intents.get("NoFootAirWalk"), - motion.animation.current_name); + motion.animation.motion_intents.get("TailGrab"), + motion.animation.motion_intents.get("NoFootAirWalk"), + motion.animation.current_name + ); } if motion.animation.current_name.as_deref() == Some("2FT_AIR_GRAB_N_TAIL_0_CYC") { assert!(motion.animation.motion_intents.contains_key("TailGrab")); - assert!(motion.animation.motion_intents.contains_key("NoFootAirWalk")); - assert_eq!(motion.score_packet.grab.map(|(name, _)| name), - Some(skate_core::animation::skeleton_input::name::encode(b"tailgrab_airwalk"))); + assert!( + motion + .animation + .motion_intents + .contains_key("NoFootAirWalk") + ); + assert_eq!( + motion.score_packet.grab.map(|(name, _)| name), + Some(skate_core::animation::skeleton_input::name::encode( + b"tailgrab_airwalk" + )) + ); assert_eq!(skater.pose_generation, physics.ticks); return; } @@ -155,10 +201,15 @@ fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stic saw_grab |= grabbing; saw_release |= saw_grab && grab_frames > 35 && !grabbing; if matches!(grab_frames, 3 | 34 | 36 | 50 | 65) { - eprintln!("Grab hand={grab_hand:?} stick={grab_stick:?} frame={grab_frames} trigger={triggers:?} AG={:?}/{:?} MG={:?}/{:?} animation={:?} flags={:08x}", - controls.action_intents.get("LeftAirGrab"), controls.action_intents.get("RightAirGrab"), - anim.motion_intents.get("WantsLeftAirGrab"), anim.motion_intents.get("WantsRightAirGrab"), - anim.current_name, skater.player_input.processed.flags_2468); + eprintln!( + "Grab hand={grab_hand:?} stick={grab_stick:?} frame={grab_frames} trigger={triggers:?} AG={:?}/{:?} MG={:?}/{:?} animation={:?} flags={:08x}", + controls.action_intents.get("LeftAirGrab"), + controls.action_intents.get("RightAirGrab"), + anim.motion_intents.get("WantsLeftAirGrab"), + anim.motion_intents.get("WantsRightAirGrab"), + anim.current_name, + skater.player_input.processed.flags_2468 + ); } } if saw_air && air_stick.is_some() { @@ -208,7 +259,10 @@ fn roll_off_sequence(air_stick: Option, grab_hand: Option, grab_stic } } assert!(saw_air, "Raw pushing never entered the actual Air family"); - assert!(!tailwalk, "Raw airborne LT + right stick up + B did not produce the Tailwalk cycle"); + assert!( + !tailwalk, + "Raw airborne LT + right stick up + B did not produce the Tailwalk cycle" + ); if grab_hand.is_some() { assert!(saw_grab, "Grab never became active"); assert!(saw_release, "Grab never released while airborne"); @@ -230,7 +284,7 @@ fn stock_starting_ramp_reaches_floor_without_air() { let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); + let mut physics = GamePhysics::load_with_terrain(root, ground::Terrain::Course).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); @@ -238,19 +292,39 @@ fn stock_starting_ramp_reaches_floor_without_air() { for tick in 0..600 { input.sample_raw_for_test(skate_core::input::xbox::XboxState { buttons: if tick >= 12 { 0x1000 } else { 0 }, - triggers: [0; 2], left: [0; 2], right: [0; 2], + triggers: [0; 2], + left: [0; 2], + right: [0; 2], }); let mut actions = input.player_actions(); - controls.update(&mut actions, physics.settings.step.simulation.time_step, - physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204); - frame::advance(&mut physics, &mut skater, &mut controls, - &graphs, &mut actions, true, &mut camera) - .unwrap_or_else(|e| panic!("Ramp tick{tick}: {e}")); - assert_ne!(skater.player_state.current().category(), 200, - "Ramp entered air at tick{tick}, deck={:?}", physics.board.bodies()[6].rates.position); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|e| panic!("Ramp tick{tick}: {e}")); + assert_ne!( + skater.player_state.current().category(), + 200, + "Ramp entered air at tick{tick}, deck={:?}", + physics.board.bodies()[6].rates.position + ); let deck = physics.board.bodies()[6].rates.position; if deck.z > 19.0 { - assert!(deck.y < ground::FLOOR_HEIGHT + 0.5, "Did not descend to floor: {deck:?}"); + assert!( + deck.y < ground::FLOOR_HEIGHT + 0.5, + "Did not descend to floor: {deck:?}" + ); assert!(physics.riding.ground.wheel_contact_count > 0); eprintln!("Reached lower floor without air at tick{tick}: {deck:?}"); return; diff --git a/crates/skate-game/src/tests/bail_geometry.rs b/crates/skate-game/src/tests/bail_geometry.rs new file mode 100644 index 00000000..036184ef --- /dev/null +++ b/crates/skate-game/src/tests/bail_geometry.rs @@ -0,0 +1,76 @@ +//! Test-only measurements, not solver limits or substitute physical constraints. +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::physics::{assembly::BodySnapshot, joint_records::default_joint_records}; + +#[derive(Default, Debug)] +pub(super) struct Geometry { + pub board_anchor_peak: (f64, usize, usize), + pub leg_anchor_peak: (f64, usize, usize), + pub basis_gram_peak: (f64, usize), +} + +impl Geometry { + pub fn observe(&mut self, physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { + for (i, joint) in default_joint_records().iter().enumerate() { + let distance = separation( + &physics.board.bodies()[joint.live_body_a().index()], + &physics.board.bodies()[joint.live_body_b().index()], + &joint.frames.words, + ); + if distance > self.board_anchor_peak.0 { + self.board_anchor_peak = (distance, tick, i); + } + } + for joint in &skater.skeleton_joints.records { + if !(15..=22).contains(&joint.child) { + continue; + } + let distance = separation( + &skater.skeleton.bodies()[joint.child], + &skater.skeleton.bodies()[joint.parent], + &joint.frames.words, + ); + if distance > self.leg_anchor_peak.0 { + self.leg_anchor_peak = (distance, tick, joint.child); + } + } + for body in physics + .board + .bodies() + .iter() + .chain(skater.skeleton.bodies()) + { + let columns = body.rates.basis.columns; + for i in 0..3 { + for j in 0..3 { + let dot: f64 = (0..3) + .map(|k| columns[i][k] as f64 * columns[j][k] as f64) + .sum(); + let error = (dot - if i == j { 1.0 } else { 0.0 }).abs(); + if error > self.basis_gram_peak.0 { + self.basis_gram_peak = (error, tick); + } + } + } + } + } +} + +fn separation(a: &BodySnapshot, b: &BodySnapshot, frames: &[u32; 20]) -> f64 { + let point = |body: &BodySnapshot, offset: usize| -> [f64; 3] { + let p = body.rates.position; + let translation = [p.x, p.y, p.z]; + core::array::from_fn(|i| { + translation[i] as f64 + + (0..3) + .map(|j| { + body.rates.basis.columns[j][i] as f64 + * f32::from_bits(frames[offset + j]) as f64 + }) + .sum::() + }) + }; + let pa = point(a, 4); + let pb = point(b, 12); + (0..3).map(|i| (pb[i] - pa[i]).powi(2)).sum::().sqrt() +} diff --git a/crates/skate-game/src/tests/board_bail_assembly.rs b/crates/skate-game/src/tests/board_bail_assembly.rs new file mode 100644 index 00000000..c45af302 --- /dev/null +++ b/crates/skate-game/src/tests/board_bail_assembly.rs @@ -0,0 +1,148 @@ +//! Measurement-only production bail probe; no positional correction or fitted limits. +//! Include as a sibling of wipeout_playback in the parent-owned test module. +use super::*; +use skate_core::{ + input::xbox::XboxState, + physics::{joint_records::default_joint_records, skeleton_animation_record::compose_affine}, + player::state::PhysicalStateId, +}; + +#[test] +#[ignore = "requires private stock animation banks and collections; diagnostic, not parity proof"] +fn raw_bail_reports_board_joint_anchors_and_render_mapping() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let joints = default_joint_records(); + let mut settled = 0; + let mut requested = false; + let mut entered = None; + let mut maxima = [0.0_f32; 6]; + let mut maximum_ticks = [0; 6]; + for tick in 0..1200 { + let request = settled >= 60 && !requested; + requested |= request; + input.sample_raw_for_test(XboxState { + buttons: if request { 0x00C0 } else { 0 }, + triggers: if request { [255; 2] } else { [0; 2] }, + left: [0; 2], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| panic!("board assembly tick{tick}: {error}")); + let state = skater.player_state.current(); + if !requested { + if state == PhysicalStateId::PhysicsGround + && physics.riding.ground.wheel_contact_count > 0 + { + settled += 1; + } else { + settled = 0; + } + } + if state == PhysicalStateId::WipeoutGround && entered.is_none() { + entered = Some(tick); + } + // Same COM + rotated local anchors used in JointJacobian::Build82AE3BC8, + // host joint_builder.rs127..128/229..232. Not geometric part centers. + let anchor = |part: usize, words: &[u32]| { + let body = &physics.board.bodies()[part]; + let local: [f32; 3] = std::array::from_fn(|i| f32::from_bits(words[i])); + let basis = body.rates.basis.columns; + let p = body.rates.position; + let p = [p.x, p.y, p.z]; + std::array::from_fn::<_, 3, _>(|i| { + basis[2][i].mul_add( + local[2], + basis[1][i].mul_add(local[1], basis[0][i] * local[0]), + ) + p[i] + }) + }; + let gaps = joints.map(|joint| { + let a = anchor(joint.live_body_a().index(), &joint.frames.words[4..7]); + let b = anchor(joint.live_body_b().index(), &joint.frames.words[12..15]); + let gap = (0..3).map(|i| (a[i] - b[i]).powi(2)).sum::().sqrt(); + assert!( + gap.is_finite(), + "Nonfinite board anchors tick{tick}: {joint:?}; a={a:?} b={b:?}" + ); + gap + }); + // Track the ordinary post-bail interval separately from constructor settling. + let mut new_maximum = false; + if entered.is_some() { + for i in 0..6 { + if gaps[i] > maxima[i] { + maxima[i] = gaps[i]; + maximum_ticks[i] = tick; + new_maximum = true; + } + } + } + if request + || entered == Some(tick) + || (entered.is_some() && (tick % 60 == 0 || new_maximum)) + { + eprintln!( + "board anchors tick{tick} state={state:?} gaps_m={gaps:?} max_m={maxima:?} max_ticks={maximum_ticks:?} hook={:?}", + physics.board.hook().drive.dynamics + ); + for (i, joint) in joints.iter().enumerate() { + eprintln!( + "joint{i} A={:?} B={:?} anchorA={:?} anchorB={:?}", + joint.live_body_a(), + joint.live_body_b(), + anchor(joint.live_body_a().index(), &joint.frames.words[4..7]), + anchor(joint.live_body_b().index(), &joint.frames.words[12..15]) + ); + } + // Render globals are animation-space. Convert to world before comparing + // them with physical bodies; retain full bases to expose scale/shear. + for bone in skater.skeleton_output.pose.board_bones.all() { + let world = compose_affine( + &skater.animated_skeleton.roots.animation_to_world, + &skater.render_pose[bone], + ); + eprintln!( + "board render tick{tick} bone={} parent={} authored_local={:?} world={world:?}", + skater.animation.evaluator.frames.bone_names[bone], + skater.animation.evaluator.frames.parents[bone], + skate_core::animation::output::sqt_to_matrix(skater.animation.pose[bone]) + ); + } + eprintln!( + "board physical tick{tick} parts={:?} skeleton_record_deck={:?}", + physics.board.part_transforms(), + skater.skeleton.record.pose[0] + ); + } + if entered.is_some_and(|start| tick - start >= 900) { + eprintln!( + "BOARD_BAIL_ASSEMBLY_RESULT post_bail_max_m={maxima:?} ticks={maximum_ticks:?}" + ); + return; + } + } + panic!("Did not complete900 post-bail ticks; entered={entered:?}"); +} diff --git a/crates/skate-game/src/tests/hippy_playback.rs b/crates/skate-game/src/tests/hippy_playback.rs new file mode 100644 index 00000000..fc6483c2 --- /dev/null +++ b/crates/skate-game/src/tests/hippy_playback.rs @@ -0,0 +1,147 @@ +//! Real raw input, stock graphs, shared physical solve and camera; no state injection. +use super::*; +use skate_core::player::state::PhysicalStateId; + +#[test] +#[ignore = "requires private stock graph/animation/physics assets"] +fn raw_controller_hippy_jump_returns_to_riding() { + run_jump(25, false, 1); +} + +#[test] +#[ignore = "requires private stock graph/animation/physics assets"] +fn hippy_charge_repeat_and_rolling_gameplay() { + for hold in [12, 60] { + run_jump(hold, false, 1); + } + run_jump(25, false, 2); + run_jump(25, true, 1); +} + +fn run_jump(hold: usize, pushing: bool, repetitions: usize) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut entered = false; + let mut descended = false; + let mut landed = false; + let mut peak = f32::NEG_INFINITY; + let mut launch_height = 0.0; + let mut landing_ticks = 0; + let mut previous_landing = false; + let mut landing_count = 0; + let mut board_launch = None; + let mut rolling_distance = 0.0_f32; + for tick in 0..(180 * repetitions + 120) { + let charge = tick >= 45 && tick < 45 + 180 * repetitions && (tick - 45) % 180 < hold; + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if charge { + 0x5000 + } else if pushing && (12..32).contains(&tick) { + 0x1000 + } else { + 0 + }, + triggers: [0; 2], + left: [0; 2], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + controls.publish_gestures( + physics.animation_profile.physics_mode, + skater.player_input.physical.state.state_16, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| { + panic!( + "Hippy tick={tick}: {error}; state={:?}; root={:?}", + skater.player_state.current(), + skater.animated_skeleton.roots.animation_to_world[3] + ) + }); + let state = skater.player_state.current(); + let height = skater.skeleton.record.centre_of_mass[1]; + assert!(height.is_finite(), "nonfinite physical COM at {tick}"); + if state == PhysicalStateId::LandingOnDeck { + if !previous_landing { + board_launch = Some(physics.board.bodies()[6].rates.position); + } + if let Some(start) = board_launch { + let now = physics.board.bodies()[6].rates.position; + rolling_distance = rolling_distance + .max(((now.x - start.x).powi(2) + (now.z - start.z).powi(2)).sqrt()); + } + if !entered { + launch_height = height; + } + entered = true; + landing_ticks += 1; + peak = peak.max(height); + descended |= height < peak - 0.1; + assert!(skater.landing_deck.manager.trajectory_valid_164); + assert_eq!(skater.ground_lifecycle.board_animated_290, 0); + } else if entered && state == PhysicalStateId::PhysicsGround { + landed = true; + if previous_landing { + landing_count += 1; + } + assert!(!skater.landing_deck.manager.trajectory_valid_164); + } + if tick % 40 == 0 || previous_landing != (state == PhysicalStateId::LandingOnDeck) { + eprintln!( + "hippy tick={tick} state={state:?} com_y={height} time={} can_land={} near={}", + skater.landing_on_deck.state.time_to_land, + skater.landing_deck.fill().can_land_316, + skater.landing_on_deck.state.near_deck + ); + } + previous_landing = state == PhysicalStateId::LandingOnDeck; + assert_eq!(skater.pose_generation, physics.ticks); + assert!(camera.frame.is_some()); + } + assert!(entered, "Raw A+X never entered stock hippy graph/state503"); + assert!( + peak > launch_height + 0.15, + "physical rider never rose: {launch_height} -> {peak}" + ); + assert!(descended, "physical rider did not descend from apex"); + assert!( + landed, + "physical rider never returned to riding; final={:?}", + skater.player_state.current() + ); + assert_eq!( + landing_count, repetitions, + "hold={hold} pushing={pushing}: missing re-entry/landing" + ); + assert!( + landing_ticks > 5 && landing_ticks < 100 * repetitions, + "landing state stalled: {landing_ticks}" + ); + if pushing { + assert!( + rolling_distance > 0.1, + "board stopped during rolling hippy jump" + ); + } +} diff --git a/crates/skate-game/src/tests/manual_playback.rs b/crates/skate-game/src/tests/manual_playback.rs new file mode 100644 index 00000000..d97b9b70 --- /dev/null +++ b/crates/skate-game/src/tests/manual_playback.rs @@ -0,0 +1,114 @@ +//! Raw controller -> authored manual states -> real shared solve and release. +use super::*; +use skate_core::player::state::PhysicalStateId; + +#[test] +#[ignore = "requires private stock graph/animation/physics assets"] +fn raw_controller_manuals_enter_balance_and_release_in_gameplay() { + for sign in [-1_i16, 1] { + run_manual(sign); + } +} + +fn run_manual(sign: i16) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut manual_seen = false; + let mut balance_seen = false; + let mut controller_seen = false; + let mut timer_seen = false; + let mut release_seen = false; + for tick in 0..330 { + // A sustained partial stick is not a Flickit flick. Use the actual + // converter and gesture pipeline, including its authored exclusions. + let holding = (45..195).contains(&tick); + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if (12..40).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], + left: [0; 2], + right: if holding { [0, sign * 22000] } else { [0; 2] }, + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + controls.publish_gestures( + physics.animation_profile.physics_mode, + skater.player_input.physical.state.state_16, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| { + panic!( + "Manual sign={sign} tick={tick}: {error}; state={:?}; balance={}; deck={:?}", + skater.player_state.current(), + skater.animation_input.fields.balance, + physics.board.bodies()[6].rates + ) + }); + manual_seen |= skater.animation.motion.flags.manualing; + balance_seen |= + skater.animation.motion.flags.manualing && skater.animation_input.fields.balance != 0.0; + controller_seen |= skater.ground.manual.elapsed > 0.0 + && skater.ground.manual.angular_correction != 0.0 + && skater.player_state.current() == PhysicalStateId::PhysicsGround; + if tick >= 195 { + assert!(controls.action_intents.get("Manual").is_none()); + assert!( + skater + .animation + .motion + .animation + .motion_intents + .get("Manual") + .is_none() + ); + timer_seen |= skater.animation.motion.riding.manual_out_timer > 0.0; + release_seen |= !skater.animation.motion.flags.manualing + && skater.animation_input.fields.balance == 0.0 + && skater.ground.manual.elapsed == 0.0; + } + assert_eq!(skater.pose_generation, physics.ticks); + assert!(camera.frame.is_some()); + assert_eq!(physics.exchange.output().unwrap().tick + 1, physics.ticks); + } + assert!(manual_seen, "Authored manual never entered: sign={sign}"); + assert!( + balance_seen, + "Manual never published physical balance: sign={sign}" + ); + assert!( + controller_seen, + "Manual never reached Ground controller: sign={sign}" + ); + assert!( + timer_seen, + "Manual exit never armed authored timer: sign={sign}" + ); + assert!( + release_seen, + "Manual controller did not clear after release: sign={sign}" + ); + assert_eq!(skater.animation.motion.riding.manual_out_timer, 0.0); + eprintln!( + "Manual sign={sign}: authored entry, physical balance/controller, release, pose and camera passed {} ticks", + physics.ticks + ); +} diff --git a/crates/skate-game/src/tests/map_startup.rs b/crates/skate-game/src/tests/map_startup.rs new file mode 100644 index 00000000..5258bbfb --- /dev/null +++ b/crates/skate-game/src/tests/map_startup.rs @@ -0,0 +1,391 @@ +//! Production startup/frame integration, following physics_startup.rs. +//! Parent physics.rs must declare this as a child module with: +//! #[cfg(test)] #[path = "tests/map_startup.rs"] mod map_startup; +//! Private inputs are loaded at runtime; these checks do not establish TU3 parity. +use super::*; +use crate::{camera::CameraRuntime, graph_runtime::StockGraphs, input::ControllerInput}; +use skate_data::skate_map::SkateMap; +use std::path::{Path, PathBuf}; + +const TICKS: u64 = 24; +// Numerical assertion tolerances, not simulation settings. +const POSE_EPSILON: f32 = 0.002; +const OFFSET: [f32; 3] = [137.0, 7.0, -83.0]; + +fn asset_root() -> PathBuf { + std::env::var_os("SKATE3_ASSET_ROOT") + .map(PathBuf::from) + .expect("set SKATE3_ASSET_ROOT to the current clone's converted stock assets") +} + +fn demo() -> SkateMap { + SkateMap::parse(include_bytes!("../../../../maps/format-demo.skate")).unwrap() +} + +// Independent +90 degree Y rotation: catches a reversed heading convention. +fn rotate([x, y, z]: [f32; 3]) -> [f32; 3] { + [z, y, -x] +} + +fn relocate(point: [f32; 3]) -> [f32; 3] { + let rotated = rotate(point); + std::array::from_fn(|i| rotated[i] + OFFSET[i]) +} + +fn xyz(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} + +fn close(actual: [f32; 3], expected: [f32; 3], context: &str) { + assert!( + actual + .iter() + .zip(expected) + .all(|(a, b)| (a - b).abs() < POSE_EPSILON), + "{context}: actual={actual:?}, expected={expected:?}" + ); +} + +struct Gameplay { + physics: GamePhysics, + skater: SkaterRuntime, + controls: PlayerControls, + input: ControllerInput, + camera: CameraRuntime, +} + +impl Gameplay { + fn load(root: &Path, graphs: &StockGraphs, map: &SkateMap) -> Self { + crate::skate_world::validate_runtime(map).unwrap(); + // Deliberately pass Course: Some(map) must supersede the terrain. + let physics = GamePhysics::load_with_world(root, ground::Terrain::Course, Some(map)) + .unwrap_or_else(|error| panic!("Map {:?} physics startup: {error}", map.name)); + let skater = SkaterRuntime::load(root, graphs, &physics, "normal").unwrap(); + Self { + physics, + skater, + controls: PlayerControls::default(), + input: ControllerInput::default(), + camera: CameraRuntime::load(root).unwrap(), + } + } + + fn advance(&mut self, graphs: &StockGraphs) { + // A connected, neutral controller exercises the normal graph/input path. + // No forced state, velocity, contact, camera or animation overrides. + self.input + .sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, + triggers: [0; 2], + left: [0; 2], + right: [0; 2], + }); + let mut actions = self.input.player_actions(); + self.controls + .update_for_physics(&mut actions, &self.physics, &self.skater, &self.camera) + .unwrap(); + frame::advance( + &mut self.physics, + &mut self.skater, + &mut self.controls, + graphs, + &mut actions, + true, + &mut self.camera, + ) + .unwrap_or_else(|error| panic!("Map gameplay tick {}: {error}", self.physics.ticks)); + self.assert_publication(); + } + + fn assert_publication(&self) { + let tick = self.physics.ticks; + assert!(!self.physics.failed, "Physics failed at tick {tick}"); + for body in self + .physics + .board + .bodies() + .iter() + .chain(self.skater.skeleton.bodies()) + { + assert!( + xyz(body.rates.position).into_iter().all(f32::is_finite), + "Non-finite body position at tick {tick}" + ); + assert!( + body.rates + .basis + .columns + .iter() + .flatten() + .all(|v| v.is_finite()), + "Non-finite body orientation at tick {tick}" + ); + } + assert!(!self.skater.render_pose.is_empty()); + assert!( + self.skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Non-finite render pose at tick {tick}" + ); + assert!( + self.skater + .animated_skeleton + .roots + .animation_to_world + .iter() + .flatten() + .all(|v| v.is_finite()), + "Non-finite player root at tick {tick}" + ); + assert_eq!(self.skater.pose_generation, tick); + assert_eq!(self.skater.animation.ticks, tick); + let camera = self + .camera + .frame + .as_ref() + .expect("Gameplay camera must publish"); + assert!(camera.position.iter().all(|v| v.is_finite())); + assert!(camera.basis.columns.iter().flatten().all(|v| v.is_finite())); + assert!(camera.field_of_view_degrees.is_finite()); + assert!(camera.field_of_view_degrees > 0.0 && camera.field_of_view_degrees < 180.0); + let subject = self + .camera + .latest_subject + .as_ref() + .expect("Missing camera subject"); + assert_eq!( + subject.tick, + tick - 1, + "Camera consumed stale physical output" + ); + let published_com = self + .skater + .player_input + .physical + .reckoning + .vector_64 + .map(f32::from_bits); + close( + subject.pose.center_of_mass[..3].try_into().unwrap(), + published_com[..3].try_into().unwrap(), + "Camera must consume the completed physical COM publication", + ); + } + + fn run_supported_startup(&mut self, graphs: &StockGraphs, map: &SkateMap) { + let mut saw_solved_wheel = false; + let mut grounded_ticks = 0; + for _ in 0..TICKS { + self.advance(graphs); + grounded_ticks += usize::from(self.physics.riding.ground.wheel_contact_count > 0); + for report in self.physics.board.contact_reports() { + assert!( + xyz(report.normal_force_on_a) + .into_iter() + .all(f32::is_finite) + ); + saw_solved_wheel |= report.part.index() < 4 + && matches!( + report.other, + skate_core::physics::board_step::CollisionBody::StaticWorld + ) + && xyz(report.normal_force_on_a) + .into_iter() + .any(|force| force != 0.0); + } + } + assert_eq!(self.physics.ticks, TICKS); + assert!( + grounded_ticks > 0, + "Map {:?}: wheel queries never found support", + map.name + ); + assert!( + self.physics.riding.ground.wheel_contact_count > 0, + "Map {:?}: no wheel support after {TICKS} neutral gameplay ticks", + map.name + ); + assert!( + saw_solved_wheel, + "Map {:?}: no solved wheel contact reaction", + map.name + ); + eprintln!( + "Map {:?}: spawn={:?}, yaw={}, ticks={}, grounded_ticks={}, deck={:?}, camera={:?}", + map.name, + map.spawn, + map.heading, + self.physics.ticks, + grounded_ticks, + self.physics.board.part_transforms()[BodyId::Deck.index()].translation, + self.camera.frame + ); + } +} + +#[test] +#[ignore = "requires SKATE3_ASSET_ROOT with private stock skater, graph and camera assets"] +fn moved_demo_starts_board_skater_and_camera_at_authored_spawn_and_yaw() { + let root = asset_root(); + let assets = skate_data::GameAssets::load(&root).unwrap(); + let graphs = StockGraphs::load(&root, &assets).unwrap(); + let original = demo(); + let mut moved = demo(); + moved.spawn = relocate(original.spawn); + moved.heading += std::f32::consts::FRAC_PI_2; + for vertex in &mut moved.geometry.vertices { + vertex.position = relocate(vertex.position); + vertex.normal = rotate(vertex.normal); + } + for triangle in &mut moved.geometry.collision { + triangle.points = triangle.points.map(relocate); + } + let baseline = Gameplay::load(&root, &graphs, &original); + let mut gameplay = Gameplay::load(&root, &graphs, &moved); + assert_eq!( + gameplay.physics.world.triangles().len(), + moved.geometry.collision.len() + ); + for (loaded, authored) in gameplay + .physics + .world + .triangles() + .iter() + .zip(&moved.geometry.collision) + { + for (actual, expected) in loaded.triangle.vertices.iter().zip(authored.points) { + close( + xyz(*actual), + expected, + "Production world must contain moved map collision", + ); + } + } + for (actual, original) in gameplay + .physics + .board + .part_transforms() + .iter() + .zip(baseline.physics.board.part_transforms()) + { + close( + xyz(actual.translation), + relocate(xyz(original.translation)), + "Board part spawn", + ); + for (actual, original) in actual.basis.columns.iter().zip(original.basis.columns) { + close(*actual, rotate(original), "Board part yaw"); + } + } + let wheel = gameplay.physics.board.part_transforms()[0].translation; + assert!( + (wheel.y - gameplay.physics.settings.wheel_radius - moved.spawn[1]).abs() < POSE_EPSILON, + "Map spawn must anchor the authored wheel bottom to the ground" + ); + assert_eq!(gameplay.skater.skeleton.bodies().len(), 26); + for (actual, original) in gameplay + .skater + .skeleton + .part_transforms() + .iter() + .zip(baseline.skater.skeleton.part_transforms()) + { + close( + actual[3][..3].try_into().unwrap(), + relocate(original[3][..3].try_into().unwrap()), + "Skater body spawn", + ); + for axis in 0..3 { + close( + actual[axis][..3].try_into().unwrap(), + rotate(original[axis][..3].try_into().unwrap()), + "Skater body yaw", + ); + } + } + let actual = gameplay.skater.animated_skeleton.roots.animation_to_world; + let original_root = baseline.skater.animated_skeleton.roots.animation_to_world; + close( + actual[3][..3].try_into().unwrap(), + relocate(original_root[3][..3].try_into().unwrap()), + "Presented player root spawn", + ); + for axis in 0..3 { + close( + actual[axis][..3].try_into().unwrap(), + rotate(original_root[axis][..3].try_into().unwrap()), + "Presented player root yaw", + ); + } + gameplay.run_supported_startup(&graphs, &moved); + let deck = gameplay.physics.board.part_transforms()[BodyId::Deck.index()].translation; + assert!( + deck.y > moved.spawn[1], + "Deck fell below the moved demo floor" + ); + let camera = gameplay.camera.frame.as_ref().unwrap(); + // A broad startup acceptance bound: catches a camera left at the origin. + // It does not prescribe a chase distance or replace the stock camera tuning. + let camera_distance = camera.position[..3] + .iter() + .zip(moved.spawn) + .map(|(a, b)| (a - b).powi(2)) + .sum::() + .sqrt(); + assert!( + camera_distance < 20.0, + "Camera did not follow moved spawn: {camera_distance}m" + ); +} + +#[test] +#[ignore = "requires SKATE3_ASSET_ROOT and SKATE_MAP_TEST_PATH pointing to a private extracted .skate map"] +fn private_extracted_map_supports_production_gameplay_startup() { + let root = asset_root(); + let path = std::env::var_os("SKATE_MAP_TEST_PATH") + .map(PathBuf::from) + .expect("set SKATE_MAP_TEST_PATH to the extracted .skate package"); + let map = SkateMap::load(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + let assets = skate_data::GameAssets::load(&root).unwrap(); + let graphs = StockGraphs::load(&root, &assets).unwrap(); + let mut gameplay = Gameplay::load(&root, &graphs, &map); + assert!(!gameplay.physics.world.triangles().is_empty()); + let [x, y, z] = map.spawn; + let support = gameplay + .physics + .world + .query_thin_line(Vector3::new(x, y + 1.0, z), Vector3::new(x, y - 10.0, z)) + .unwrap(); + assert!( + support.is_some(), + "{}: authored spawn has no supporting map collision", + path.display() + ); + let deck = gameplay.physics.board.part_transforms()[BodyId::Deck.index()]; + close( + xyz(deck.translation), + [ + x, + y + gameplay.physics.settings.wheel_radius + - gameplay.physics.settings.authored[0].translation.y, + z, + ], + "Private map deck spawn", + ); + let (sin, cos) = map.heading.sin_cos(); + close( + deck.basis.columns[2], + [sin, 0.0, cos], + "Private map deck heading", + ); + eprintln!( + "Private map {}: {} collision triangles, spawn support={support:?}", + path.display(), + gameplay.physics.world.triangles().len() + ); + gameplay.run_supported_startup(&graphs, &map); +} diff --git a/crates/skate-game/src/tests/offboard_air_playback.rs b/crates/skate-game/src/tests/offboard_air_playback.rs new file mode 100644 index 00000000..fea04bb4 --- /dev/null +++ b/crates/skate-game/src/tests/offboard_air_playback.rs @@ -0,0 +1,147 @@ +//! Production controller/graph/solver regression across an authored walking edge. +use super::*; +use skate_core::player::state::PhysicalStateId; + +pub(super) fn edge_world( + material: skate_core::physics::contact::RetailContactMaterial, + extent: f32, + floor_y: f32, +) -> BoardWorld { + // Test-authored collision geometry, not imported render triangles or a + // support override: four triangles queried by the real world/analyzer. + let mut triangles = Vec::new(); + for (extent, height, tag) in [(extent, ground::HEIGHT, 1), (50.0, floor_y, 0)] { + let corners = [ + Vector3::new(-extent, height, -extent), + Vector3::new(extent, height, -extent), + Vector3::new(extent, height, extent), + Vector3::new(-extent, height, extent), + ]; + for indices in [[0, 2, 1], [0, 3, 2]] { + triangles.push( + skate_core::physics::board_world::WorldTriangle::from_vertices( + indices.map(|i| corners[i]), + material, + tag, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, + [1.0; 3], + 0.0, + ) + .unwrap(), + ); + } + } + use skate_core::physics::board_world::query_metadata::{ + Bounds, QueryMesh, QueryMetadata, QueryPool, + }; + use skate_core::physics::drive_frames::RetailAffineTransform; + // Explicit test-level collision metadata, using the same authoring contract + // as Terrain::Flat. No synthetic support/contact publication. + let metadata = QueryMetadata { + packed_surfaces: vec![0; triangles.len()], + meshes: vec![QueryMesh { + triangle_range: 0..triangles.len(), + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(triangles.iter().flat_map(|t| t.triangle.vertices)) + .unwrap(), + matching_group: -1, + rejection_flags: 0, + geometry: 1, + pool: QueryPool::Ground, + }], + static_edges: Vec::new(), + island_flags: 0, + }; + BoardWorld::with_query_metadata(triangles, metadata).unwrap() +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn offboard_walk_off_edge_runs_ground_air_and_landing() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + physics.world = edge_world(physics.settings.floor_material, 2.0, ground::FLOOR_HEIGHT); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut grounded = false; + let mut air_tick = None; + let mut landed_tick = None; + for tick in 0..1200 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick == 20 { 0x8000 } else { 0 }, + triggers: [0; 2], + left: if (120..480).contains(&tick) { + [0, 24000] + } else { + [0; 2] + }, + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls + .update_for_physics(&mut actions, &physics, &skater, &camera) + .unwrap(); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|e| panic!("Offboard edge tick{tick}: {e}")); + let state = skater.player_state.current(); + if state == PhysicalStateId::BipedGround { + grounded = true; + if air_tick.is_some() { + landed_tick.get_or_insert(tick); + } + } + if state == PhysicalStateId::BipedAir { + assert!(grounded, "Air entered before walking Ground at tick{tick}"); + air_tick.get_or_insert(tick); + assert_eq!( + skater.player_input.physical.off_board.trajectory_valid_331, 1, + "BipedAir Fill did not publish its trajectory at tick{tick}" + ); + assert_eq!( + skater.player_input.physical.off_board.flag_304, 0, + "BipedAir invented an object-grab gate at tick{tick}" + ); + } + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Invalid offboard pose at tick{tick}" + ); + assert!( + camera + .frame + .as_ref() + .expect("completed camera") + .position + .iter() + .all(|v| v.is_finite()), + "Invalid offboard camera at tick{tick}" + ); + } + assert!( + air_tick.is_some(), + "Walking off the authored platform never entered BipedAir" + ); + assert!( + landed_tick.is_some(), + "BipedAir never returned to BipedGround on the lower floor" + ); +} diff --git a/crates/skate-game/src/tests/offboard_jump_playback.rs b/crates/skate-game/src/tests/offboard_jump_playback.rs new file mode 100644 index 00000000..c36b77c4 --- /dev/null +++ b/crates/skate-game/src/tests/offboard_jump_playback.rs @@ -0,0 +1,358 @@ +//! Raw X -> stock OB_Jump -> Ground launch -> real Air/landing regression. +//! Source: input8259C260, attribute82BDBA88, Ground82D32338/82D32D38, +//! launch82D7BA78. Acceptance bounds are not native tuning/parity fixtures. +use super::*; +use skate_core::{input::xbox::XboxState, player::state::PhysicalStateId}; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_x_offboard_jump_connects_input_ground_launch_air_and_landing() { + jump_playback(false); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_x_offboard_jump_with_thrown_board_connects_ground_air_and_landing() { + jump_playback(true); +} + +fn jump_playback(throw_board: bool) { + jump_playback_with_tweak(throw_board, false); +} + +#[test] +#[ignore = "requires private stock assets; connected raw right-stick gameplay regression"] +fn raw_right_stick_four_directions_reach_air_tweak_with_held_board() { + jump_playback_with_tweak(false, true); +} + +#[test] +#[ignore = "requires private stock assets; connected raw right-stick gameplay regression"] +fn raw_right_stick_four_directions_reach_air_tweak_with_released_board() { + jump_playback_with_tweak(true, true); +} + +fn jump_playback_with_tweak(throw_board: bool, tweak: bool) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut jump_intents = 0; + let mut launch_tick = None; + let mut air_tick = None; + let mut landing_tick = None; + let mut air_frames = 0; + let mut landed_frames = 0; + let mut launch_height = None; + let mut maximum_height = f32::NEG_INFINITY; + let mut saw_upward_physical_velocity = false; + let mut saw_completed_selector = false; + let mut saw_stock_end_com_adjustment = false; + let mut tweak_directions = [false; 4]; + for tick in 0..1200 { + let before_state = skater.player_state.current(); + if tick == 400 { + assert_eq!( + before_state, + PhysicalStateId::BipedGround, + "Raw Y did not settle into Ground before the jump" + ); + assert_ne!( + skater.biped_ground.contact.flags_176 & 1, + 0, + "Jump fixture has no actual analyzer support" + ); + assert!(skater.skateboard_controller.fields.system_on_452); + if throw_board { + assert_released(&physics, &skater, tick); + } else { + assert_eq!( + skater.skateboard_controller.fields.state_448, 1, + "Held variant must begin carrying the board through the real owner" + ); + } + launch_height = Some(skater.skeleton.record.centre_of_mass[1]); + } + //Stock input.cfg: GP_XFace=X=Button14, native action78 -> bit23. + //Hold X for sixty frames:8259C260 emits only on the rising edge. + // Release before native82BBB030's <0.1s landing boundary. Holding a + // tweak into that boundary issues a separate native bit16 request. + let tweak_index = if tweak && (410..442).contains(&tick) { + Some((tick - 410) / 8) + } else { + None + }; + let right = tweak_index + .map(|i| [[24000, 0], [-24000, 0], [0, 24000], [0, -24000]][i]) + .unwrap_or([0; 2]); + input.sample_raw_for_test(XboxState { + buttons: if tick == 20 { + 0x8000 + } else if (400..460).contains(&tick) { + 0x4000 + } else { + 0 + }, + //8259C260 publishes throw/retrieve on a full RT rising edge. The + //stock graph and actual possession owner decide the release. + triggers: if throw_board && (200..260).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + left: [0; 2], + right, + }); + let mut actions = input.player_actions(); + controls + .update_for_physics(&mut actions, &physics, &skater, &camera) + .unwrap(); + frame::advance(&mut physics, &mut skater, &mut controls, + &graphs, &mut actions, true, &mut camera) + .unwrap_or_else(|e| { + let p = &skater.player_input.processed; + let controller = &skater.biped_ground.controller.state; + panic!("Offboard jump tick{tick}, thrown={throw_board}, prior={before_state:?}: {e}; \ + states={}/{} categories={}/{} flags={:08x}/{:08x}/{:08x}/{:08x}; \ + controller_up={:?} controller_motion_up={:?} velocity608={:?} velocity912={:?} \ + steering={} speed704={} edge={:?} raw_xz={:?}; \ + processed_up544={:?} processed_forward224={:?} ground_result={:?} localCOM10960={:?}", + p.state_2504, p.state_2508, p.category_2516, p.category_2512, + p.flags_2472, p.flags_2476, p.flags_2480, p.flags_2484, + controller.frame_output.frame[1], controller.motion.frame_0[1], + p.vectors_544_560_592_608[3].map(f32::from_bits), + p.vectors_880_896_912_928_944[2].map(f32::from_bits), + controller.intent.steering, controller.motion.speed_704, controller.intent.edge_target, + [skater.animation_input.extra.biped_world_x, skater.animation_input.extra.biped_world_z], + p.vectors_544_560_592_608[0].map(f32::from_bits), + p.effective_anim_transform_192[2].map(f32::from_bits), + skater.biped_ground.result, skater.animated_skeleton.record.centre_of_mass); + }); + let state = skater.player_state.current(); + let p = &skater.player_input.processed; + if skater + .animation + .motion + .action_intents + .contains_key("OB_Jump") + { + jump_intents += 1; + assert_eq!(tick, 400, "Jump intent was not the raw X rising edge"); + } + if tick >= 400 { + if throw_board { + assert_released(&physics, &skater, tick); + } + let com = skater.skeleton.record.centre_of_mass; + let velocity = skater.skeleton.record.centre_of_mass_velocity; + maximum_height = maximum_height.max(com[1]); + saw_upward_physical_velocity |= velocity[1] > 0.1; + assert!( + velocity.iter().all(|v| v.is_finite()), + "Nonfinite jump velocity tick{tick}" + ); + assert_joints(&skater, tick); + if state == PhysicalStateId::BipedGround + && p.flags_2476 & 0x80000 != 0 + && skater.biped_ground.ground.flags_144_to_150[2] + { + launch_tick.get_or_insert(tick); + assert_ne!( + skater.biped_ground.contact.flags_176 & 1, + 0, + "Expected an explicit supported jump, not falling off support" + ); + let output = &skater.player_input.physical.off_board; + assert_eq!(output.flag_328, 1, "Ground Fill dropped the launch flag"); + assert_eq!( + output.scalar_32, 1., + "Ground Fill dropped the selector scalar" + ); + let selector = &skater.offboard_air_selector.core; + assert!( + selector.sampling.pending_8492, + "Ground did not submit its real Air query" + ); + assert_eq!(selector.launch.kind_108, 6); + assert_eq!( + selector.launch.kind_112, 3, + "Launch was not native jump mode4" + ); + assert!( + selector.launch.velocity_0[1] > 0., + "Jump packet has no upward velocity" + ); + } + if state == PhysicalStateId::BipedAir { + let end_com = skater.animation_input.fields.animation_end_com; + let translation = skater.animation_input.fields.animation_translation; + if end_com[1] > 0.1 + && translation[1] <= 0.1 + && skater.biped_air.state.flags_544_550[0] + { + //Native82D2EFF0/F014 reads0xB40, not0xB30. The stock + //jump's distinct attributes make a swapped callsite fail. + assert!( + skater.biped_air.state.flags_544_550[1], + "Air did not consume stock AnimEndCOM at tick{tick}" + ); + saw_stock_end_com_adjustment = true; + } + if let Some(index) = tweak_index { + let animation = &skater.animation.motion.animation; + if animation.channels.has("AirBodyTweak") { + assert!(animation.motion_intents.contains_key("OB_AirBodyTweakX")); + assert!(animation.motion_intents.contains_key("OB_AirBodyTweakY")); + tweak_directions[index] = true; + } + } + assert!( + launch_tick.is_some(), + "Air preceded the completed Ground jump packet" + ); + assert!( + landing_tick.is_none(), + "A held X/republication caused another Air entry" + ); + air_tick.get_or_insert(tick); + air_frames += 1; + let selector = &skater.offboard_air_selector.core; + saw_completed_selector |= + !selector.sampling.pending_8492 && selector.selected_index.is_some(); + assert_eq!( + skater.player_input.physical.off_board.trajectory_valid_331, 1, + "Air Fill has no real trajectory tick{tick}" + ); + } + if air_tick.is_some() && state == PhysicalStateId::BipedGround { + landing_tick.get_or_insert(tick); + landed_frames += 1; + } + if state != before_state || launch_tick == Some(tick) { + eprintln!( + "Jump tick{tick}: {before_state:?}->{state:?} COM={com:?} velocity={velocity:?} flags={:08x}/{:08x}/{:08x} support={:x} remaining={} launch={launch_tick:?}", + p.flags_2476, + p.flags_2480, + p.flags_2484, + skater.biped_ground.contact.flags_176, + skater.biped_air.state.time_remaining_444 + ); + } + } + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Nonfinite jump pose tick{tick}" + ); + assert!( + camera + .frame + .as_ref() + .expect("completed camera") + .position + .iter() + .all(|v| v.is_finite()), + "Nonfinite jump camera tick{tick}" + ); + if landed_frames >= 120 { + break; + } + } + assert_eq!( + jump_intents, 1, + "A single raw X edge must produce one AG jump intent" + ); + if tweak { + assert!( + tweak_directions.into_iter().all(|seen| seen), + "Raw right-stick directions did not all reach the live AirBodyTweak channel: {tweak_directions:?}" + ); + } + assert!( + launch_tick.is_some(), + "Stock jump animation never produced a Ground launch" + ); + assert!( + air_frames >= 8, + "Jump did not sustain an airborne interval: {air_frames}" + ); + assert!(saw_completed_selector, "Real jump query was never consumed"); + assert!( + saw_stock_end_com_adjustment, + "Jump did not exercise the distinct stock AnimEndCOM/AnimTrans inputs" + ); + assert!( + saw_upward_physical_velocity, + "Jump did not raise the physical skeleton" + ); + assert!( + maximum_height > launch_height.expect("jump attempted") + 0.1, + "Physical COM never rose above the supported launch height" + ); + assert!( + landing_tick.is_some() && landed_frames >= 120, + "Jump did not return to sustained Ground through the native selector" + ); +} + +fn assert_released(physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { + assert!( + matches!(skater.skateboard_controller.fields.state_448, 2 | 3), + "Raw RT did not leave the board free at tick{tick}: {:?}", + skater.skateboard_controller.fields + ); + assert_eq!( + skater.board_possession.state.selected_hand_424, 2, + "Free-board jump retained a selected hand at tick{tick}" + ); + assert_eq!( + physics.board.hook().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2], + "Free-board jump rearmed the deck drive at tick{tick}" + ); + for hand in &skater.board_possession.state.hands { + assert_eq!( + hand.dynamics, + [[0, 0, 0, 2]; 2], + "Free-board jump rearmed a hand drive at tick{tick}" + ); + } +} + +fn assert_joints(skater: &SkaterRuntime, tick: usize) { + let bodies = skater.skeleton.bodies(); + let anchor = |part: usize, words: &[u32]| -> [f32; 3] { + let body = &bodies[part]; + let local: [f32; 3] = std::array::from_fn(|i| f32::from_bits(words[i])); + let basis = body.rates.basis.columns; + let p = body.rates.position; + let position = [p.x, p.y, p.z]; + std::array::from_fn(|i| { + position[i] + basis[0][i] * local[0] + basis[1][i] * local[1] + basis[2][i] * local[2] + }) + }; + for joint in &skater.skeleton_joints.records { + let child = anchor(joint.child, &joint.frames.words[4..7]); + let parent = anchor(joint.parent, &joint.frames.words[12..15]); + let gap = (0..3) + .map(|i| (child[i] - parent[i]).powi(2)) + .sum::() + .sqrt(); + //Visible-failure acceptance bound shared with the dismount regression. + assert!( + gap < 0.35, + "Jump joint failure tick{tick}: {}->{} gap={gap}", + joint.parent, + joint.child + ); + } +} diff --git a/crates/skate-game/src/tests/offboard_midair_playback.rs b/crates/skate-game/src/tests/offboard_midair_playback.rs new file mode 100644 index 00000000..bd094aaf --- /dev/null +++ b/crates/skate-game/src/tests/offboard_midair_playback.rs @@ -0,0 +1,358 @@ +//! Raw-input reproduction of dismounting while falling; no forced physical state. +use super::*; +use skate_core::player::state::PhysicalStateId; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_y_while_falling_preserves_biped_air_joint_and_velocity_continuity() { + for release_board in [false, true] { + falling_dismount(release_board, if release_board { 20.0 } else { 8.0 }, false); + } +} + +#[test] +#[ignore = "requires private stock assets; complete midair dismount/landing regression"] +fn raw_midair_dismount_at_two_heights_runs_through_sustained_landing() { + for drop in [2.0, 6.0] { + for released in [false, true] { + falling_dismount(released, drop, true); + } + } +} + +fn falling_dismount(release_board: bool, drop: f32, complete_landing: bool) { + eprintln!( + "Midair scenario release_board={release_board} drop={drop} complete_landing={complete_landing}" + ); + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // A real authored drop gives the stock dismount animation time to run. + // These are fixture dimensions, not gameplay constants or fake contacts. + let floor_y = ground::HEIGHT - drop; + physics.world = + super::offboard_air_tests::edge_world(physics.settings.floor_material, 2.0, floor_y); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut y_tick = None; + let mut air_tick = None; + let mut air_frames = 0; + let mut continuity_frames = 0; + let mut saw_selector_completion = false; + let mut saw_held_board = false; + let mut release_tick = None; + let mut released_air_frames = 0; + let mut landed_frames = 0; + let mut previous_state = skater.player_state.current(); + for tick in 0..1200 { + let before_velocity = skater.skeleton.record.centre_of_mass_velocity; + let before_position = skater.skeleton.record.centre_of_mass; + let before_state = skater.player_state.current(); + let before_board_state = skater.skateboard_controller.fields.state_448; + let falling = before_state.category() == 200 && before_velocity[1] < 0.0; + let press_y = y_tick.is_none() && falling; + let buttons = if press_y { + y_tick = Some(tick); + 0x8000 + } else if y_tick.is_none() && tick >= 12 && before_state.category() != 200 { + 0x1000 + } else { + 0 + }; + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons, + //Use the same raw RT input as the verified held->released ground + //replay, after actual Air entry. The stock graph owns its timing. + triggers: if release_board && air_frames > 0 { + [0, 255] + } else { + [0; 2] + }, + left: [0; 2], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls + .update_for_physics(&mut actions, &physics, &skater, &camera) + .unwrap(); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| { + panic!("Midair dismount tick{tick}, Y={y_tick:?}, prior={before_state:?}: {error}") + }); + let state = skater.player_state.current(); + if state != previous_state + || press_y + || before_board_state != skater.skateboard_controller.fields.state_448 + { + trace_air(&skater, tick); + eprintln!( + "Midair tick{tick}: {previous_state:?}->{state:?}, Y={y_tick:?}; COM={:?}; velocity={:?}; root_velocity={:?}; board={:?}; hand={}; selector={:?}", + skater.skeleton.record.centre_of_mass, + skater.skeleton.record.centre_of_mass_velocity, + skater.skeleton_input.root_velocity, + skater.skateboard_controller.fields, + skater.board_possession.state.selected_hand_424, + skater.offboard_air_selector.core.selected_index, + ); + previous_state = state; + } + if state == PhysicalStateId::BipedAir { + assert!( + y_tick.is_some(), + "BipedAir preceded requested dismount at tick{tick}" + ); + if air_tick.is_none() { + air_tick = Some(tick); + } + air_frames += 1; + let board_state = skater.skateboard_controller.fields.state_448; + saw_held_board |= board_state == 1; + if release_board && board_state == 2 { + assert!( + saw_held_board, + "Released scenario never held the board in Air" + ); + release_tick.get_or_insert(tick); + released_air_frames += 1; + } + let selector = &skater.offboard_air_selector.core; + saw_selector_completion |= + !selector.sampling.pending_8492 && selector.selected_index.is_some(); + assert!( + skater.skateboard_controller.fields.system_on_452, + "Air entry left the canonical possession controller off at tick{tick}" + ); + } + if y_tick.is_some() { + //Air548 remains set until its native landing condition. Ground + //selection while it is set exposes the wrong-width flag read. + if before_state == PhysicalStateId::BipedAir + && state == PhysicalStateId::BipedGround + && skater.biped_air.state.flags_544_550[4] + { + panic!("Air selected Ground with retained Air328=true at tick{tick}"); + } + assert_physical_joints(&skater, tick); + let velocity = skater.skeleton.record.centre_of_mass_velocity; + assert!( + velocity.iter().all(|v| v.is_finite()), + "Nonfinite actual COM velocity at tick{tick}: {velocity:?}" + ); + assert!( + skater + .skeleton_input + .root_velocity + .iter() + .all(|v| v.is_finite()), + "Nonfinite drive-root velocity at tick{tick}" + ); + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Nonfinite rendered pose at tick{tick}" + ); + assert!( + camera + .frame + .as_ref() + .expect("completed camera") + .position + .iter() + .all(|v| v.is_finite()), + "Nonfinite gameplay camera at tick{tick}" + ); + // Acceptance bounds, not original force/solver constants. Apply only + // during the first four Air/release ticks, away from the floor; + // contact impulses on later landing are not a continuity failure. + if state == PhysicalStateId::BipedAir + && (air_frames <= 4 || release_tick.is_some_and(|start| tick < start + 4)) + && before_position[1] > floor_y + 2.0 + { + continuity_frames += 1; + let delta_velocity = distance(velocity, before_velocity); + let displacement = distance(skater.skeleton.record.centre_of_mass, before_position); + let travel = + magnitude(before_velocity) * physics.settings.step.simulation.time_step; + assert!( + delta_velocity < 10.0, + "Midair COM velocity discontinuity tick{tick}: delta={delta_velocity}, before={before_velocity:?}, after={velocity:?}" + ); + assert!( + displacement < travel + 0.5, + "Midair COM position discontinuity tick{tick}: step={displacement}, previous-speed travel={travel}" + ); + } + if skater.skateboard_controller.fields.state_448 == 2 { + assert_eq!( + physics.board.hook().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2], + "Released deck still driven tick{tick}" + ); + assert_eq!( + skater.board_possession.state.selected_hand_424, 2, + "Released board still has a hand owner tick{tick}" + ); + for hand in &skater.board_possession.state.hands { + assert_eq!( + hand.dynamics, + [[0, 0, 0, 2]; 2], + "Released hand drive still armed tick{tick}" + ); + } + } + } + // Continue beyond entry and query completion, but do not turn this + // isolated reproduction into a wipeout/recovery feature acceptance. + if air_tick.is_some() && state == PhysicalStateId::BipedGround { + landed_frames += 1; + } + if if complete_landing { + landed_frames >= 120 + } else { + (!release_board && air_frames >= 12) || (release_board && released_air_frames >= 24) + } { + break; + } + } + if complete_landing { + assert!( + landed_frames >= 120, + "Dismount drop={drop}, released={release_board} did not sustain landing: {landed_frames}; state={:?}", + skater.player_state.current() + ); + } + assert!( + y_tick.is_some(), + "Raw push never produced observed falling onboard Air" + ); + assert!(air_tick.is_some(), "Falling raw Y never entered BipedAir"); + assert!( + air_frames >= 12, + "BipedAir did not survive twelve update frames" + ); + assert!( + continuity_frames >= 1, + "No airborne velocity-continuity interval observed" + ); + assert!( + saw_selector_completion, + "No real selector candidate completed while BipedAir was observed" + ); + if release_board { + assert!( + release_tick.is_some(), + "Raw RT never released the held board in Air" + ); + //The original20m continuity fixture must still cover24 release ticks. + //A2m drop lands sooner; the new case instead requires120 landed ticks + //above, plus proof the raw release actually occurred while airborne. + let required_release_frames = if complete_landing { 1 } else { 24 }; + assert!( + released_air_frames >= required_release_frames, + "Did not observe{required_release_frames} released-board Air ticks: {released_air_frames}" + ); + } +} + +fn trace_air(skater: &SkaterRuntime, tick: usize) { + let p = &skater.player_input.processed; + let air = &skater.biped_air.state; + let selector = &skater.offboard_air_selector.core; + let selected = &selector.sampling.selection; + eprintln!( + "Air trace tick{tick}: states={}/{} categories={}/{} timer={} \ + flags2472/76/80/84/88={:08x}/{:08x}/{:08x}/{:08x}/{:08x}; \ + Airframe={} remaining={} duration={} valid={} contact={:?} normal={:?} \ + flags544..550={:?} output320={}; pending8492={} ready8494={} requery8499={} \ + selection={:?} present={} valid6200={} landingframe={} sampleframe={} \ + scalar8392={} query={:?}", + p.state_2504, + p.state_2508, + p.category_2516, + p.category_2512, + p.state_timer_2664, + p.flags_2472, + p.flags_2476, + p.flags_2480, + p.flags_2484, + p.flags_2488, + air.frame_452, + air.time_remaining_444, + air.duration_448, + air.result.valid_404, + air.result.contact_position_336, + air.result.normal_304, + air.flags_544_550, + u8::from(air.output().flag_320), + selector.sampling.pending_8492, + selector.sampling.preinitialized_8494, + selector.requery_pending_8499, + selector.selected_index, + selected.result_present_3888, + selected.valid_6200, + selected.landing_frame_8480, + selector.sampling.frame_8484, + selected.scalar_8392, + selector + .selected_index + .and_then(|i| selector.predictions.get(i)) + .map(|p| p.result), + ); +} + +fn magnitude(v: [f32; 4]) -> f32 { + (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() +} +fn distance(a: [f32; 4], b: [f32; 4]) -> f32 { + magnitude(std::array::from_fn(|i| a[i] - b[i])) +} +fn assert_physical_joints(skater: &SkaterRuntime, tick: usize) { + let bodies = skater.skeleton.bodies(); + let anchor = |part: usize, words: &[u32]| -> [f32; 3] { + let body = &bodies[part]; + let local: [f32; 3] = std::array::from_fn(|i| f32::from_bits(words[i])); + let basis = body.rates.basis.columns; + let position = body.rates.position; + let position = [position.x, position.y, position.z]; + std::array::from_fn(|i| { + position[i] + basis[0][i] * local[0] + basis[1][i] * local[1] + basis[2][i] * local[2] + }) + }; + for joint in &skater.skeleton_joints.records { + let child = anchor(joint.child, &joint.frames.words[4..7]); + let parent = anchor(joint.parent, &joint.frames.words[12..15]); + let gap = (0..3) + .map(|i| (child[i] - parent[i]).powi(2)) + .sum::() + .sqrt(); + // Same visible-failure acceptance bound as the real ground replay. + assert!( + gap < 0.35, + "Midair joint failure tick{tick}: {} -> {} gap={gap}; board={:?}; hand={}; root_velocity={:?}; flags={:08x}/{:08x}/{:08x}", + joint.parent, + joint.child, + skater.skateboard_controller.fields, + skater.board_possession.state.selected_hand_424, + skater.skeleton_input.root_velocity, + skater.player_input.processed.flags_2476, + skater.player_input.processed.flags_2480, + skater.player_input.processed.flags_2484, + ); + } +} diff --git a/crates/skate-game/src/tests/offboard_recall_playback.rs b/crates/skate-game/src/tests/offboard_recall_playback.rs new file mode 100644 index 00000000..a39e7bd6 --- /dev/null +++ b/crates/skate-game/src/tests/offboard_recall_playback.rs @@ -0,0 +1,898 @@ +//! Real raw controller -> stock graphs -> possession -> shared solver checks. +//! Acceptance regressions, not original-game or Xenon parity fixtures. +use super::*; +use skate_core::input::xbox::XboxState; +use skate_core::player::state::PhysicalStateId; + +struct Replay { + physics: GamePhysics, + skater: SkaterRuntime, + controls: PlayerControls, + input: crate::input::ControllerInput, + camera: crate::camera::CameraRuntime, + graphs: crate::graph_runtime::StockGraphs, +} +impl Replay { + fn load() -> Self { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let physics = GamePhysics::load(root).unwrap(); + let skater = + SkaterRuntime::load(root, &graphs, &physics, crate::physics::PHYSICS_MODE).unwrap(); + let camera = crate::camera::CameraRuntime::load(root).unwrap(); + Self { + physics, + skater, + camera, + graphs, + controls: PlayerControls::default(), + input: crate::input::ControllerInput::default(), + } + } + fn tick(&mut self, tick: usize, buttons: u16, triggers: [u8; 2]) { + self.tick_raw( + tick, + XboxState { + buttons, + triggers, + left: [0; 2], + right: [0; 2], + }, + ); + } + fn tick_raw(&mut self, tick: usize, raw: XboxState) { + let previous = ( + self.skater.player_state.current(), + self.skater.skateboard_controller.fields.state_448, + ); + self.input.sample_raw_for_test(raw); + let mut actions = self.input.player_actions(); + self.controls + .update_for_physics(&mut actions, &self.physics, &self.skater, &self.camera) + .unwrap(); + frame::advance( + &mut self.physics, + &mut self.skater, + &mut self.controls, + &self.graphs, + &mut actions, + true, + &mut self.camera, + ) + .unwrap_or_else(|e| panic!("Recall/remount tick{tick}: {e}")); + let current = ( + self.skater.player_state.current(), + self.skater.skateboard_controller.fields.state_448, + ); + if previous != current || tick % 240 == 0 || (780..900).contains(&tick) && tick % 10 == 0 { + let channel = &self.skater.animation.motion.animation.channels; + eprintln!( + "board replay tick{tick}: {previous:?}->{current:?} flags={:08x}/{:08x} progress={} physical={:?} recallAG={} mountMG={} channel={}/{}/{}", + self.skater.player_input.processed.flags_2476, + self.skater.player_input.processed.flags_2480, + self.skater.board_possession.state.retrieval.progress_200, + self.skater.animation.motion.toggle_board_physical, + self.skater + .animation + .motion + .action_intents + .contains_key("OB_RetrieveBoard"), + self.skater + .animation + .motion + .animation + .motion_intents + .contains_key("OB_Mount"), + channel.has("RetrieveBoard"), + channel.elapsed("RetrieveBoard"), + channel.remaining("RetrieveBoard") + ); + } + assert!( + self.skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Nonfinite pose tick{tick}" + ); + let roots = &self.skater.animated_skeleton.roots.animation_to_world; + assert!( + roots.iter().flatten().all(|v| v.is_finite()), + "Nonfinite FULL animation root (including W) tick{tick}: {roots:?}" + ); + assert!( + self.skater + .skeleton + .record + .centre_of_mass + .iter() + .chain(self.skater.skeleton.record.centre_of_mass_velocity.iter()) + .all(|v| v.is_finite()), + "Nonfinite full physical COM/velocity tick{tick}" + ); + assert!( + self.camera + .frame + .as_ref() + .expect("completed camera") + .position + .iter() + .all(|v| v.is_finite()), + "Nonfinite camera tick{tick}" + ); + } + fn assert_held(&self) { + let s = &self.skater; + assert_eq!(s.skateboard_controller.fields.state_448, 1); + assert!(s.skateboard_controller.fields.system_on_452); + let hand = s.board_possession.state.selected_hand_424 as usize; + assert!(hand < 2, "Held board has no physical hand owner"); + assert_ne!( + s.board_possession.state.hands[hand].dynamics, + [[0, 0, 0, 2]; 2] + ); + assert!(s.board_possession_live.volumes.deck); + assert_eq!(s.player_input.physical.off_board.flag_311, 1); + } +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_right_bumper_reaches_offboard_stock_arm_ready_channel() { + let mut r = Replay::load(); + let mut ready = false; + for tick in 0..300 { + let held = (200..230).contains(&tick); + r.tick( + tick, + if tick == 20 { + 0x8000 + } else if held { + 0x0200 + } else { + 0 + }, + [0; 2], + ); + if std::env::var_os("SKATE3_TRACE_OFFBOARD_ANIMATION").is_some() + && matches!(tick, 199 | 220 | 260) + { + let animation = &r.skater.animation; + let globals = animation.evaluator.hierarchy(&animation.pose).unwrap(); + eprintln!( + "RB_ARM tick={tick} stance={:?} flags={:?} selected_hand={}", + animation.stance(), + animation.motion.animation.skater_animation_flags, + r.skater.board_possession.state.selected_hand_424 + ); + for (bone, name) in animation.evaluator.frames.bone_names.iter().enumerate() { + if name.contains("ARM") || name.contains("HAND") || name.contains("SHOULDER") { + eprintln!( + "RB_ARM tick={tick} bone={bone}/{name} mirror={} animation={:?} solved_render={:?}", + animation.evaluator.frames.mirror_indices[bone], + globals[bone][3], + r.skater.render_pose[bone][3] + ); + } + } + } + if tick >= 200 { + assert_eq!( + r.skater.player_state.current(), + PhysicalStateId::BipedGround + ); + assert_eq!( + r.controls.action_intents.get("GrabWorld").copied(), + held.then_some(1.0), + "RB AG publication at tick{tick}" + ); + assert_eq!( + r.skater + .animation + .motion + .animation + .motion_intents + .get("GrabWorld") + .copied(), + held.then_some(1.0), + "Stock AG-to-MG publication at tick{tick}" + ); + if held { + ready |= r + .skater + .animation + .motion + .animation + .channels + .has("SkitchAntic"); + assert_ne!( + r.skater.player_input.processed.flags_2476 & 0x400000, + 0, + "Stock offboard AttachIntent did not publish GrabWorld" + ); + } + } + } + assert!( + ready, + "Stock Shove never started the offboard arm-ready channel" + ); + assert!( + !r.skater + .animation + .motion + .animation + .channels + .has("SkitchAntic"), + "Arm-ready channel persisted after RB release and its fade" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn connected_biped_ground_advances_shared_board_steering() { + let mut r = Replay::load(); + for tick in 0..200 { + r.tick(tick, if tick == 20 { 0x8000 } else { 0 }, [0; 2]); + } + assert_eq!( + r.skater.player_state.current(), + PhysicalStateId::BipedGround + ); + r.skater.ground.steering.deck_tilt = 0.75; + r.skater.ground.steering.targets = [0.25, -0.5]; + r.skater.ground.steering.activation_time = [0.1, 0.1]; + let mut expected = r.skater.ground.steering; + r.tick(200, 0, [0; 2]); + let p = &r.skater.player_input.processed; + expected.update( + 0.0, + r.skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + assert_eq!( + r.skater.ground.steering, expected, + "Ground must call the existing board steering owner once after feet, before possession" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn diagnostic_released_board_jump_apex_rb_arm_response() { + // Diagnostic comparison, not a backwards-man compatibility pass. No Y + // remount is injected yet: isolate the user's reported RB arm response. + let mut runs = [Replay::load(), Replay::load()]; + if std::env::var_os("SKATE3_TRACE_OFFBOARD_ANIMATION").is_some() { + use skate_data::animation_metadata::TreeMetadata; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let metadata = skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(); + let mut pending = vec!["B_OBAIR_BODYTWEAK_NB_INTO".to_string()]; + while let Some(name) = pending.pop() { + match metadata.tree(&name).unwrap() { + TreeMetadata::PhaseBlend(tree) => { + eprintln!("APEX_STOCK {tree:?}"); + pending.extend(tree.children.iter().cloned()); + } + TreeMetadata::Clip(clip) => { + for attribute in &clip.attributes { + if attribute.name.to_ascii_lowercase().contains("bodytweak") { + eprintln!( + "APEX_STOCK clip={} offset={:#x} parameter={attribute:?}", + clip.name, clip.source_offset + ); + } + } + } + _ => panic!("Extend diagnostic for authored tree {name}"), + } + } + } + let mut apex_tick = None; + let mut rose = false; + for tick in 0..560 { + let s = &runs[0].skater; + if s.player_state.current() == PhysicalStateId::BipedAir && tick > 400 { + let vy = s.skeleton.record.centre_of_mass_velocity[1]; + rose |= vy > 0.0; + if rose && vy <= 0.0 { + apex_tick.get_or_insert(tick); + } + } + for (variant, r) in runs.iter_mut().enumerate() { + let rb = variant == 1 && apex_tick == Some(tick); + let buttons = if tick == 20 { + 0x8000 + } else if tick == 400 { + 0x4000 + } else if rb { + 0x0200 + } else { + 0 + }; + r.tick( + tick, + buttons, + if (200..260).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + ); + if tick == 400 { + assert!( + matches!(r.skater.skateboard_controller.fields.state_448, 2 | 3), + "Diagnostic did not release the board before jumping" + ); + } + if apex_tick.is_some_and(|apex| (apex..=apex + 10).contains(&tick)) { + let a = &r.skater.animation; + let globals = a.evaluator.hierarchy(&a.pose).unwrap(); + eprintln!( + "APEX_RB variant={variant} tick={tick} apex={apex_tick:?} state={:?} stance={:?} AG={:?} MG={:?} flags={:08x}/{:08x}/{:08x} board={}", + r.skater.player_state.current(), + a.stance(), + r.controls.action_intents.get("GrabWorld"), + a.motion.animation.motion_intents.get("GrabWorld"), + r.skater.player_input.processed.flags_2476, + r.skater.player_input.processed.flags_2480, + r.skater.player_input.processed.flags_2484, + r.skater.skateboard_controller.fields.state_448 + ); + for name in ["RIGHTHAND", "LEFTHAND"] { + let bone = a + .evaluator + .frames + .bone_names + .iter() + .position(|n| n == name) + .unwrap(); + eprintln!( + "APEX_RB variant={variant} tick={tick} {name} animation={:?} solved_render={:?}", + globals[bone][3], r.skater.render_pose[bone][3] + ); + } + } + } + } + assert!( + apex_tick.is_some(), + "Diagnostic did not reach a physical jump apex" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_repeated_walk_turn_throw_recall_jump_remount_preserves_full_roots_and_joints() { + let mut r = Replay::load(); + // The uninterrupted route crosses the default flat fixture's z=50 edge + // at tick4585, before cycle3's commanded jump. Give this endurance test + // actual collision geometry covering its route; keep the dedicated ledge + // tests and every landing/ownership/root/joint assertion unchanged. + r.physics.world = super::offboard_air_tests::edge_world( + r.physics.settings.floor_material, + 250.0, + ground::HEIGHT - 100.0, + ); + //Three uninterrupted cycles exceed both reported827 and3315 histories. + //All timing and movement below are test commands, not physics parameters. + for cycle in 0..3 { + let mut dismounted = false; + let mut released = false; + let mut recall_pulse = false; + let mut returning = false; + let mut caught = false; + let mut air_frames = 0; + let mut landed_frames = 0; + let mut mounted = false; + for local in 0..1800 { + let tick = cycle * 1800 + local; + let side = if cycle % 2 == 0 { 20000 } else { -20000 }; + let left = match local { + 120..220 => [0, 24000], + 220..280 => [side, 0], + 280..320 => [-side, 12000], + 480..540 => [side, 18000], + 540..600 => [-side, 0], + 930..1030 => [0, 18000], + 1030..1120 => [side, 12000], + 1160..1220 => [-side, 8000], + _ => [0; 2], + }; + r.tick_raw( + tick, + XboxState { + buttons: if local == 20 || local == 1400 { + 0x8000 + } else if (1050..1110).contains(&local) { + 0x4000 + } else { + 0 + }, + triggers: if (400..460).contains(&local) || (800..860).contains(&local) { + [0, 255] + } else { + [0; 2] + }, + left, + right: [0; 2], + }, + ); + let s = &r.skater; + let state = s.player_state.current(); + let controller = s.skateboard_controller.fields; + dismounted |= local < 400 && state == PhysicalStateId::BipedGround; + if (400..800).contains(&local) && matches!(controller.state_448, 2 | 3) { + released = true; + assert_eq!( + s.board_possession.state.selected_hand_424, 2, + "Free board retained a hand in cycle{cycle} tick{tick}" + ); + for hand in &s.board_possession.state.hands { + assert_eq!(hand.dynamics, [[0, 0, 0, 2]; 2]); + } + } + if (800..1050).contains(&local) { + recall_pulse |= s.player_input.processed.flags_2476 & 0x800 != 0; + if controller.state_448 == 4 { + returning = true; + assert!(!s.board_possession_live.volumes.deck); + assert!(!s.board_possession_live.volumes.trucks); + assert!(!s.board_possession_live.volumes.wheels); + } + if returning && controller.state_448 == 1 { + r.assert_held(); + caught = true; + } + } + if (1050..1400).contains(&local) { + if state == PhysicalStateId::BipedAir { + air_frames += 1; + } + if air_frames > 0 && state == PhysicalStateId::BipedGround { + landed_frames += 1; + } + } + if local >= 1400 && matches!(state.category(), 100 | 200) { + mounted = true; + assert!(!controller.system_on_452); + assert_eq!(controller.state_448, 0); + assert_eq!(s.board_possession.state.selected_hand_424, 2); + } + if tick >= 120 { + assert_joint_anchors(s, tick); + } + } + assert!( + dismounted && released && recall_pulse && returning && caught, + "Incomplete possession cycle{cycle}: dismounted={dismounted} free={released} pulse={recall_pulse} returning={returning} caught={caught}" + ); + assert!( + air_frames >= 8 && landed_frames >= 60, + "Walking/turning jump cycle{cycle} lacks sustained Air/landing: {air_frames}/{landed_frames}" + ); + assert!( + mounted, + "Remount failed after throw/recall/walking jump cycle{cycle}" + ); + assert_eq!( + r.skater.player_state.current(), + PhysicalStateId::PhysicsGround, + "Cycle{cycle} did not finish stably onboard before next raw dismount" + ); + } +} + +fn assert_joint_anchors(skater: &SkaterRuntime, tick: usize) { + let bodies = skater.skeleton.bodies(); + let anchor = |part: usize, words: &[u32]| -> [f32; 3] { + let b = &bodies[part]; + let local: [f32; 3] = std::array::from_fn(|i| f32::from_bits(words[i])); + let basis = b.rates.basis.columns; + let p = b.rates.position; + let position = [p.x, p.y, p.z]; + std::array::from_fn(|i| { + position[i] + basis[0][i] * local[0] + basis[1][i] * local[1] + basis[2][i] * local[2] + }) + }; + for joint in &skater.skeleton_joints.records { + let a = anchor(joint.child, &joint.frames.words[4..7]); + let b = anchor(joint.parent, &joint.frames.words[12..15]); + let gap = (0..3).map(|i| (a[i] - b[i]).powi(2)).sum::().sqrt(); + //Visible-failure bound already used by the raw dismount/jump tests; + //this is acceptance measurement, never a solver clamp or parity claim. + assert!( + gap < 0.35, + "Combined replay joint failure tick{tick}: {}->{} gap={gap}", + joint.parent, + joint.child + ); + } +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_y_dismount_then_rt_within_six_frames_does_not_bail() { + // User reproduction: idle onboard, Y, then RT a few frames later. The + // first unexpected bail is the failure, not only later joint explosion. + // delay0 is the Y-only control; 1..6 are delayed RT; case7 is simultaneous. + for delay in 0..=7usize { + eprintln!("DISMOUNT_RT_CASE delay={delay}"); + let mut r = Replay::load(); + let mut dismounted = false; + for tick in 0..240usize { + let rt = if delay == 7 { 20 } else { 20 + delay }; + r.tick( + tick, + if tick == 20 { 0x8000 } else { 0 }, + if delay != 0 && (rt..rt + 30).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + ); + if std::env::var_os("SKATE3_TRACE_OFFBOARD_ANIMATION").is_some() + && (20..=24).contains(&tick) + { + let s = &r.skater; + eprintln!( + "YRT_INPUT delay={delay} tick={tick} root={:?} board_anim={:?} board_unadjusted={:?} board_drive={:?} force_mode={} trajectory={:?}", + s.animated_skeleton.roots.animation_to_world[3], + s.animated_skeleton.animation_board, + s.animated_skeleton.unadjusted_board, + s.skeleton_input.drive_frames[0], + s.skeleton_input.force_mode, + s.animated_skeleton.motion.next_trajectory + ); + for (bone, name) in s.animation.evaluator.frames.bone_names.iter().enumerate() { + if bone == 0 || name.eq_ignore_ascii_case("Skateboard") { + eprintln!( + "YRT_POSE delay={delay} tick={tick} bone={bone}/{name} sqt={:?}", + s.animation.pose[bone] + ); + } + } + } + let state = r.skater.player_state.current(); + dismounted |= state.category() == 500; + assert_ne!( + state.category(), + 300, + "Y then RT caused bail: delay={delay} tick={tick} state={state:?} requested={:?} board={:?} flags={:08x?}", + r.skater.player_state.requested_state, + r.skater.skateboard_controller.fields, + [ + r.skater.player_input.processed.flags_2468, + r.skater.player_input.processed.flags_2472, + r.skater.player_input.processed.flags_2476, + r.skater.player_input.processed.flags_2480, + r.skater.player_input.processed.flags_2484 + ] + ); + } + assert!( + dismounted, + "Y never dismounted in delay={delay} reproduction" + ); + } +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn diagnostic_rt_before_y_dismount_possession_timing() { + let mut r = Replay::load(); + for tick in 0..180usize { + r.tick( + tick, + if tick == 21 { 0x8000 } else { 0 }, + if (20..50).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + ); + if (19..65).contains(&tick) { + let s = &r.skater; + eprintln!( + "RTY_TIMING tick={tick} state={:?} controller={:?} root={:?} deck={:?} physical={:?} channel={}/{}/{} flags={:08x}/{:08x}", + s.player_state.current(), + s.skateboard_controller.fields, + s.animated_skeleton.roots.animation_to_world[3], + s.animated_skeleton.animation_board[3], + s.animation.motion.toggle_board_physical, + s.animation.motion.animation.channels.has("RetrieveBoard"), + s.animation + .motion + .animation + .channels + .elapsed("RetrieveBoard"), + s.animation + .motion + .animation + .channels + .remaining("RetrieveBoard"), + s.player_input.processed.flags_2476, + s.player_input.processed.flags_2480 + ); + } + } + // Diagnostic completion is not an assertion that possession timing matches stock. +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_deck_free_predicate_tracks_completed_possession_not_not_held() { + let mut r = Replay::load(); + let mut seen = [false; 5]; + for tick in 0..1000usize { + let published_state = r.skater.skateboard_controller.fields.state_448; + r.tick( + tick, + if tick == 20 { 0x8000 } else { 0 }, + if (400..430).contains(&tick) || (800..830).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + ); + let free = crate::graph_host::motion_stock_conditions::Condition::IsDeckFree + .evaluate(&r.skater.animation.motion) + .unwrap(); + assert_eq!( + free, + matches!(published_state, 2 | 3 | 4), + "IsDeckFree tick{tick} completed board state={published_state}" + ); + if let Some(value) = seen.get_mut(published_state as usize) { + *value = true; + } + } + assert!( + seen[0] && seen[1] && seen[2] && seen[4], + "Replay must cover riding, held, released and returning: {seen:?}" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_y_and_right_trigger_adjacent_edges_preserve_roots_and_joints() { + // Reproduction commands only: exercise overlapping dismount/throw and + // remount/recall without injecting intentions or physical state changes. + for context in ["onboard", "held", "free"] { + for offset in [-1isize, 0, 1] { + eprintln!("Y_RT_CASE context={context} trigger_offset={offset}"); + let mut r = Replay::load(); + for tick in 0..1000usize { + let trigger_tick = (600isize + offset) as usize; + let preparing_release = context == "free" && (250..310).contains(&tick); + r.tick_raw( + tick, + XboxState { + buttons: if (context != "onboard" && tick == 20) || tick == 600 { + 0x8000 + } else { + 0 + }, + triggers: if preparing_release + || (trigger_tick..trigger_tick + 30).contains(&tick) + { + [0, 255] + } else { + [0; 2] + }, + left: if (450..750).contains(&tick) { + [0, 22000] + } else { + [0; 2] + }, + right: [0; 2], + }, + ); + if tick == 440 { + match context { + "onboard" => assert!(matches!( + r.skater.player_state.current().category(), + 100 | 200 + )), + "held" => r.assert_held(), + "free" => assert!(matches!( + r.skater.skateboard_controller.fields.state_448, + 2 | 3 + )), + _ => unreachable!(), + } + } + if tick >= 120 { + assert_joint_anchors(&r.skater, tick); + } + } + } + } +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_walk_turn_and_recall_without_jumping_preserves_full_roots_and_joints() { + let mut r = Replay::load(); + let mut released = false; + let mut returning = false; + let mut caught = false; + for tick in 0..3600usize { + let phase = tick % 1200; + r.tick_raw( + tick, + XboxState { + buttons: if tick == 20 { 0x8000 } else { 0 }, + triggers: if (400..430).contains(&phase) || (800..830).contains(&phase) { + [0, 255] + } else { + [0; 2] + }, + left: if tick < 120 { + [0; 2] + } else { + match phase { + 0..300 => [0, 24000], + 300..600 => [18000, 14000], + 600..900 => [-18000, 18000], + _ => [0; 2], + } + }, + right: [0; 2], + }, + ); + let state = r.skater.skateboard_controller.fields.state_448; + released |= matches!(state, 2 | 3); + returning |= state == 4; + caught |= returning && state == 1; + if tick >= 120 { + assert_joint_anchors(&r.skater, tick); + } + } + assert!( + released && returning && caught, + "Walking replay did not complete throw/recall" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_trigger_recall_returns_free_board_to_physical_hand() { + let mut r = Replay::load(); + let mut released = false; + let mut recall_intent = false; + let mut recall_attribute = false; + let mut returning = false; + let mut caught = false; + for tick in 0..1800 { + //A second right-trigger rising edge requests recall once the board is + //free. No injected intentions, animation attributes or physical states. + let triggers = if (400..460).contains(&tick) || (800..860).contains(&tick) { + [0, 255] + } else { + [0; 2] + }; + r.tick(tick, if tick == 20 { 0x8000 } else { 0 }, triggers); + if tick == 300 { + r.assert_held(); + } + let s = &r.skater; + if (400..800).contains(&tick) { + released |= matches!(s.skateboard_controller.fields.state_448, 2 | 3); + } + if tick >= 800 { + recall_intent |= s + .animation + .motion + .action_intents + .contains_key("OB_RetrieveBoard"); + recall_attribute |= s.player_input.processed.flags_2476 & 0x800 != 0; + returning |= s.skateboard_controller.fields.state_448 == 4; + if returning && s.skateboard_controller.fields.state_448 == 1 { + r.assert_held(); + caught = true; + } + } + } + assert!(released, "Trigger never released the originally held board"); + assert!( + recall_intent, + "Raw trigger never reached stock recall AG input" + ); + assert!( + recall_attribute, + "RetrieveBoard animation never emitted physical recall pulse2476bit11" + ); + assert!( + returning, + "Recall pulse never entered possession RETURNING4" + ); + assert!( + caught, + "Real retrieval never completed into held board and armed hand drive" + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_y_offboard_reaches_stock_remount_and_stops_hand_controller() { + let mut r = Replay::load(); + let mut mount_intent = false; + let mut mounting_attribute = false; + let mut mounted = false; + for tick in 0..1800 { + r.tick( + tick, + if tick == 20 || tick == 400 { 0x8000 } else { 0 }, + [0; 2], + ); + if tick == 300 { + assert_eq!( + r.skater.player_state.current(), + PhysicalStateId::BipedGround + ); + r.assert_held(); + } + let s = &r.skater; + if tick == 400 { + assert!( + s.animation + .motion + .action_intents + .contains_key("NewToggleOffBoardState"), + "Y while offboard was suppressed instead of reaching stock remount" + ); + assert!( + s.animation + .motion + .action_intents + .contains_key("ToggleOffBoardState") + ); + assert!( + !s.animation + .motion + .action_intents + .contains_key("OB_RetrieveBoard"), + "Y was incorrectly replaced with trigger recall" + ); + } + if tick >= 400 { + mount_intent |= s + .animation + .motion + .animation + .motion_intents + .contains_key("OB_Mount"); + mounting_attribute |= s.player_input.processed.flags_2480 & 0x80000 != 0; + if matches!(s.player_state.current().category(), 100 | 200) { + mounted = true; + assert!(!s.skateboard_controller.fields.system_on_452); + assert_eq!(s.skateboard_controller.fields.state_448, 0); + assert_eq!(s.board_possession.state.selected_hand_424, 2); + for hand in &s.board_possession.state.hands { + assert_eq!( + hand.dynamics, + [[0, 0, 0, 2]; 2], + "Onboard physical state retains offboard hand drive at tick{tick}" + ); + } + } + } + } + assert!(mount_intent, "Stock OffBoard AG never mapped Y to OB_Mount"); + assert!( + mounting_attribute, + "Stock mount animation never emitted OB_Mounting" + ); + assert!( + mounted, + "Stock remount never returned to an onboard physical state" + ); +} diff --git a/crates/skate-game/src/tests/offboard_root_trace.rs b/crates/skate-game/src/tests/offboard_root_trace.rs new file mode 100644 index 00000000..35edb005 --- /dev/null +++ b/crates/skate-game/src/tests/offboard_root_trace.rs @@ -0,0 +1,72 @@ +//! Opt-in diagnostics for the offboard root producer chain. Test builds only. +use super::SkaterRuntime; + +pub(crate) fn trace(tick: u64, phase: &str, s: &SkaterRuntime) { + if phase == "finish" && std::env::var_os("SKATE3_TRACE_OFFBOARD_ANIMATION").is_some() { + let render = crate::physics::offboard::skeleton_ground::audit_render_parts(s); + let legs = [11, 12, 13, 15, 17, 19, 23].map(|part| { + ( + part, + s.animated_skeleton.record.pose[part], + s.skeleton_input.drive_frames[part], + s.skeleton.record.pose[part], + ) + }); + eprintln!("POSECHAIN tick={tick} render_errors={render:?} mapped_ik_physical={legs:?}"); + } + if std::env::var_os("SKATE3_TRACE_OFFBOARD_ROOT").is_none() { + return; + } + let p = &s.player_input.processed; + let a = &s.animated_skeleton; + let b = &s.biped_ground.controller.state; + if phase == "selected" || phase == "state" { + let selector = &s.offboard_air_selector.core; + eprintln!( + "HANDOFF tick={tick} phase={phase} current={} previous={} air_active={} air_frame={} selector={:?} chosen={:?} correction={:?} ground_support={} support_frame={:?} support_velocity={:?} predicted_support={:?} ground_velocity={:?} output_velocity={:?} correction_target={:?} entry_correction={:?}", + p.state_2508, + p.state_2504, + s.biped_air.state.active, + selector.sampling.frame_8484, + selector.sampling, + selector.selected_candidate, + selector.correction_8304, + b.motion.support_id_352, + b.motion.previous_support_frame_192, + b.motion.support_velocity_256, + b.motion.predicted_support_velocity_272, + b.motion.velocity_480, + b.frame_output.velocity, + b.correction_target_592, + b.motion.correction_576 + ); + } + if phase == "state" && s.biped_air.state.active { + eprintln!( + "AIRATTR tick={tick} translation={:?} endCOM={:?} flags={:?} adjustment={:?} remaining={} blend={} target={:?}", + s.animation_input.fields.animation_translation, + s.animation_input.fields.animation_end_com, + s.biped_air.state.flags_544_550, + s.biped_air.state.adjustment_496, + s.biped_air.state.time_remaining_444, + s.biped_air.state.blend_440, + s.biped_air.state.frame_144 + ); + } + eprintln!( + "ROOT tick={tick} phase={phase} state={:?} root={:?} localCOM={:?} physicalCOM={:?} comFrame={:?} motion={:?} output={:?} bipedPosition={:?} position592={:?} velocity608={:?} velocity912={:?} airPosition={:?} airVelocity={:?}", + s.player_state.current(), + a.roots.animation_to_world, + a.record.centre_of_mass, + s.skeleton.record.centre_of_mass, + a.board_frames.com_frame[3], + b.motion.frame_0, + b.frame_output.frame, + b.position_368, + p.vectors_544_560_592_608[2].map(f32::from_bits), + p.vectors_544_560_592_608[3].map(f32::from_bits), + p.vectors_880_896_912_928_944[2].map(f32::from_bits), + s.biped_air.state.result.position_272, + s.biped_air.state.result.velocity_288 + ); +} diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs index 91e4954d..68fc91cb 100644 --- a/crates/skate-game/src/tests/physics_startup.rs +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -7,7 +7,7 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() let root = std::path::Path::new(&root); let manifest = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); - let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); // This regression isolates grounded controls on the original flat fixture. // The production box is only six metres wide: sustained kickturning leaves // it at x=-3.32, correctly selecting KnownAir and ForcePhysics2. Separate @@ -18,12 +18,21 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() Vector3::new(20.0, ground::HEIGHT, 20.0), Vector3::new(-20.0, ground::HEIGHT, 20.0), ]; - physics.world = BoardWorld::new([[0, 2, 1], [0, 3, 2]].map(|indices| { - skate_core::physics::board_world::WorldTriangle::from_vertices( - indices.map(|i| corners[i]), physics.settings.floor_material, 0, - skate_core::physics::collision::TriangleFeature::ONE_SIDED, [1.0; 3], 0.0, - ).unwrap() - }).to_vec()); + physics.world = BoardWorld::new( + [[0, 2, 1], [0, 3, 2]] + .map(|indices| { + skate_core::physics::board_world::WorldTriangle::from_vertices( + indices.map(|i| corners[i]), + physics.settings.floor_material, + 0, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, + [1.0; 3], + 0.0, + ) + .unwrap() + }) + .to_vec(), + ); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); assert_eq!( skater.animation.pose.len(), @@ -202,9 +211,11 @@ fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() ) }); assert_eq!( - skater.skeleton_input.force_mode, 1, + skater.skeleton_input.force_mode, + 1, "Stock Ground ForcePhysics Begin was lost at standing tick{tick}; state={:?}; deck={:?}; contacts={}", - skater.player_state.current(), physics.board.bodies()[6].rates.position, + skater.player_state.current(), + physics.board.bodies()[6].rates.position, physics.riding.ground.wheel_contact_count ); saw_kickturn_balance |= skater.animation_input.fields.balance != 0.0; @@ -363,7 +374,7 @@ fn customiser_equipment_reaches_ground_force_and_torque() { let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); let mut samples = Vec::new(); for hardness in [0.0_f32, 0.7, 1.0] { - let mut physics = GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); crate::customiser::apply_preferences( &serde_json::json!({"truck": hardness, "wheel": hardness}), @@ -504,3 +515,264 @@ fn customiser_styles_and_postures_change_stock_pose() { skater.animation.set_customisation(1,0); assert_eq!(skater.animation.stance(), stance); } + +#[test] +#[ignore = "requires private stock assets"] +fn raw_y_reaches_stock_dismount() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut saw_toggle = false; + let mut first_offboard_tick = None; + let mut previous_state = skater.player_state.current(); + let mut walk_start = None; + let mut walk_end = None; + let mut camera_start = None; + let mut camera_end = None; + let mut held_board_seen = false; + let mut released_board_seen = false; + for tick in 0..2400 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick == 20 { 0x8000 } else { 0 }, + triggers: if (400..460).contains(&tick) { + [0, 255] + } else { + [0; 2] + }, + left: match tick { + 120..300 => [0, 24000], + 800..920 => [24000, 0], + 920..1040 => [-24000, 0], + _ => [0; 2], + }, + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls + .update_for_physics(&mut actions, &physics, &skater, &camera) + .unwrap(); + let published_slope = skater.player_input.physical.off_board.kind_88; + let published_thin = skater.player_input.physical.off_board.flag_330 != 0; + let result = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ); + saw_toggle |= skater + .animation + .motion + .action_intents + .get("NewToggleOffBoardState") + .is_some(); + result.unwrap_or_else(|error| { + panic!( + "Y tick{tick}, input_received={saw_toggle}, state={:?}: {error}", + skater.player_state.current() + ) + }); + assert_eq!( + skater.animation.motion.ground_slope_type, + Some(published_slope), + "GroundSlopeType did not read completed OffBoard88 at tick{tick}" + ); + assert_eq!( + skater.animation.motion.biped_ground_thin, + Some(published_thin), + "IsBipedGroundThin did not read completed OffBoard330 at tick{tick}" + ); + let root_position = skater.animated_skeleton.roots.animation_to_world[3]; + held_board_seen |= skater.skateboard_controller.fields.state_448 == 1; + if held_board_seen && skater.skateboard_controller.fields.state_448 == 2 { + released_board_seen = true; + //LetGo82D75440 clears both deck-drive channels and the selected + //hand drive before entering released state2. Test the live owners. + assert_eq!( + physics.board.hook().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2], + "Deck remains driven after release tick{tick}" + ); + assert_eq!( + skater.board_possession.state.selected_hand_424, 2, + "Released board retains a hand owner at tick{tick}" + ); + for hand in &skater.board_possession.state.hands { + assert_eq!( + hand.dynamics, + [[0, 0, 0, 2]; 2], + "Hand drive remains armed after release tick{tick}" + ); + } + } + let camera_position = camera + .frame + .as_ref() + .expect("Missing gameplay camera") + .position; + assert!( + camera_position.iter().all(|v| v.is_finite()), + "Camera tick{tick}" + ); + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Non-finite rendered skeleton at tick{tick}" + ); + assert_eq!( + skater + .player_input + .physical + .reckoning + .vector_64 + .map(f32::from_bits), + skater.animated_skeleton.board_frames.centre_of_mass, + "COM output was overwritten after skeleton publication at tick{tick}" + ); + if tick == 120 { + walk_start = Some(root_position); + camera_start = Some(camera_position); + } + if tick == 299 { + walk_end = Some(root_position); + camera_end = Some(camera_position); + } + if tick == 240 { + let attrs = skater.animation_input.extra; + assert!( + attrs.offboard_magnitude > 0.1, + "Stock AG/MG did not produce offboard magnitude: {attrs:?}" + ); + assert!( + attrs.biped_world_x.abs() + attrs.biped_world_z.abs() > 0.1, + "Stock AG/MG did not publish Biped world direction: {attrs:?}" + ); + } + if (120..2400).contains(&tick) { + assert_eq!( + skater.player_state.current(), + skate_core::player::state::PhysicalStateId::BipedGround, + "Ordinary walking/trigger input left BipedGround at tick{tick}" + ); + // Measure the actual shared-solver joint anchors, not a synthetic + // stand pose. A 35cm separation is visible limb failure, not + // normal constraint compliance. This is an acceptance threshold. + let bodies = skater.skeleton.bodies(); + let anchor = |part: usize, words: &[u32]| -> [f32; 3] { + let body = &bodies[part]; + let local: [f32; 3] = std::array::from_fn(|i| f32::from_bits(words[i])); + let basis = body.rates.basis.columns; + let position = body.rates.position; + let position = [position.x, position.y, position.z]; + std::array::from_fn(|i| { + position[i] + + basis[0][i] * local[0] + + basis[1][i] * local[1] + + basis[2][i] * local[2] + }) + }; + for joint in &skater.skeleton_joints.records { + let child = anchor(joint.child, &joint.frames.words[4..7]); + let parent = anchor(joint.parent, &joint.frames.words[12..15]); + let gap = (0..3) + .map(|i| (child[i] - parent[i]).powi(2)) + .sum::() + .sqrt(); + if gap >= 0.35 { + let collision = crate::physics::input_phase::collision(&skater); + eprintln!( + "Joint failure drive input: partial={}, weight={}", + collision.partial_ragdoll, collision.drive_weight_4028 + ); + for part in [15usize, 16] { + if let Some(bone) = &skater.skeleton_drives.bones[part] { + eprintln!( + "Joint failure bone{part}: parent={:?} active={:?} dynamics={:?} frames={:?}", + bone.parent, bone.active, bone.dynamics, bone.frames + ); + } + } + eprintln!( + "Pose boundary errors: {:?}", + crate::physics::offboard::skeleton_ground::audit_render_parts(&skater) + ); + } + assert!( + gap < 0.35, + "Broken joint at tick{tick}: {} -> {}, gap={gap}; board_state={:?}; hands={:?}; ground_result={:?}; errors={:?}; com_target={:?}; flags={:08x}/{:08x}/{:08x}", + joint.parent, + joint.child, + skater.skateboard_controller.fields, + skater.board_possession.state.hands, + skater.biped_ground.result, + skater.collision_extra_errors, + skater.animated_skeleton.board_frames.com_frame, + skater.player_input.processed.flags_2476, + skater.player_input.processed.flags_2480, + skater.player_input.processed.flags_2484 + ); + } + } + if skater.player_state.current() != previous_state || tick % 120 == 0 { + for bone in skater.skeleton_output.pose.board_bones.all() { + eprintln!( + "board bone tick{tick} {} parent={} animation_local={:?} rendered={:?}", + skater.animation.evaluator.frames.bone_names[bone], + skater.animation.evaluator.frames.parents[bone], + skate_core::animation::output::sqt_to_matrix(skater.animation.pose[bone])[3], + skater.render_pose[bone][3] + ); + } + eprintln!( + "dismount tick={tick} state={:?} root={:?} com={:?} camera={:?}", + skater.player_state.current(), + skater.animated_skeleton.roots.animation_to_world[3], + skater + .player_input + .physical + .reckoning + .vector_64 + .map(f32::from_bits), + camera.frame.as_ref().map(|f| f.position) + ); + previous_state = skater.player_state.current(); + } + if skater.player_state.current().category() == 500 { + assert!(saw_toggle); + first_offboard_tick.get_or_insert(tick); + } + } + assert!( + first_offboard_tick.is_some(), + "Y did not reach off-board physics; input_received={saw_toggle}" + ); + assert!( + held_board_seen && released_board_seen, + "Replay did not exercise held-to-released board possession" + ); + let distance = + |a: [f32; 4], b: [f32; 4]| ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + // Acceptance bounds for this recorded three-second input, not gameplay clamps. + let walked = distance(walk_start.unwrap(), walk_end.unwrap()); + assert!( + walked > 1.0 && walked < 20.0, + "Implausible walking displacement: {walked}" + ); + let followed = distance(camera_start.unwrap(), camera_end.unwrap()); + assert!( + followed > 1.0 && followed < 20.0, + "Camera did not follow walking: {followed}" + ); +} diff --git a/crates/skate-game/src/tests/powerslide_playback.rs b/crates/skate-game/src/tests/powerslide_playback.rs index e631d01e..6c129faa 100644 --- a/crates/skate-game/src/tests/powerslide_playback.rs +++ b/crates/skate-game/src/tests/powerslide_playback.rs @@ -16,7 +16,9 @@ fn run_side(side: i16) { let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); let data = skate_data::collections::Collections::load(root).unwrap(); - let entry_speed = data.float("anim_motion", "power_slide", "slide_speed_threshold").unwrap(); + let entry_speed = data + .float("anim_motion", "power_slide", "slide_speed_threshold") + .unwrap(); let mut physics = GamePhysics::load(root).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); @@ -31,7 +33,8 @@ fn run_side(side: i16) { let mut saw_packet = false; let mut release_clear = false; for tick in 0..900 { - if sequence_start.is_none() && tick >= 60 + if sequence_start.is_none() + && tick >= 60 && physics.riding.motion.ground_speed > entry_speed * 1.5 && skater.player_state.current() == PhysicalStateId::PhysicsGround { @@ -44,13 +47,22 @@ fn run_side(side: i16) { _ => [0; 2], }; input.sample_raw_for_test(skate_core::input::xbox::XboxState { - buttons: if tick >= 12 && sequence_start.is_none() { 0x1000 } else { 0 }, - triggers: [0; 2], left, right: [0; 2], + buttons: if tick >= 12 && sequence_start.is_none() { + 0x1000 + } else { + 0 + }, + triggers: [0; 2], + left, + right: [0; 2], }); let mut actions = input.player_actions(); - controls.update(&mut actions, physics.settings.step.simulation.time_step, + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, physics.settings.input_magnitude_threshold, - skater.player_input.physical.scoring.capabilities_204); + skater.player_input.physical.scoring.capabilities_204, + ); frame::advance(&mut physics, &mut skater, &mut controls, &graphs, &mut actions, true, &mut camera).unwrap_or_else(|error| panic!( "Powerslide side={side} tick={tick} phase={phase:?}: {error}; state={:?}; speed={}; deck={:?}", @@ -60,44 +72,95 @@ fn run_side(side: i16) { let motion = &skater.animation.motion; let starts = motion.slide_latch.start(true) || motion.slide_latch.start(false); if matches!(phase, Some(12..=23)) { - assert!(controls.action_intents.get("RightSlideStart").is_none() - && controls.action_intents.get("LeftSlideStart").is_none(), - "Pure lateral stick generated a slide start: side={side} tick={tick}"); + assert!( + controls.action_intents.get("RightSlideStart").is_none() + && controls.action_intents.get("LeftSlideStart").is_none(), + "Pure lateral stick generated a slide start: side={side} tick={tick}" + ); assert!(!starts, "Pure lateral stick started the slide latch"); - assert_ne!(skater.player_state.current(), PhysicalStateId::SlideGround, - "Pure lateral stick entered SlideGround"); + assert_ne!( + skater.player_state.current(), + PhysicalStateId::SlideGround, + "Pure lateral stick entered SlideGround" + ); } if matches!(phase, Some(36..=95)) { saw_start |= starts; saw_slide |= skater.player_state.current() == PhysicalStateId::SlideGround; - let emitted = motion.animation.motion_attributes.iter() + let emitted = motion + .animation + .motion_attributes + .iter() .any(|attribute| attribute.name == encode(b"slide") && attribute.value != 0.0); - let published = skater.animation.attributes.entries().iter().rev() - .find(|attribute| attribute.name == encode(b"slide") && matches!(attribute.kind, 0 | 2)) - .and_then(|attribute| attribute.payload.0[0]).map(f32::from_bits); + let published = skater + .animation + .attributes + .entries() + .iter() + .rev() + .find(|attribute| { + attribute.name == encode(b"slide") && matches!(attribute.kind, 0 | 2) + }) + .and_then(|attribute| attribute.payload.0[0]) + .map(f32::from_bits); if emitted && let Some(value) = published { - assert_eq!(skater.animation_input.fields.slide.to_bits(), value.to_bits(), - "Slide changed between canonical animation packet and Skeleton input"); + assert_eq!( + skater.animation_input.fields.slide.to_bits(), + value.to_bits(), + "Slide changed between canonical animation packet and Skeleton input" + ); saw_packet |= value != 0.0; } } if matches!(phase, Some(97..)) { - for name in ["LeftSlideStart", "RightSlideStart", "LeftSlide", "RightSlide"] { - assert!(controls.action_intents.get(name).is_none(), "Released AG intent retained: {name}"); - assert!(motion.animation.motion_intents.get(name).is_none(), "Released MG intent retained: {name}"); + for name in [ + "LeftSlideStart", + "RightSlideStart", + "LeftSlide", + "RightSlide", + ] { + assert!( + controls.action_intents.get(name).is_none(), + "Released AG intent retained: {name}" + ); + assert!( + motion.animation.motion_intents.get(name).is_none(), + "Released MG intent retained: {name}" + ); } release_clear |= !motion.is_power_sliding && skater.player_state.current() != PhysicalStateId::SlideGround; } assert!(camera.frame.is_some()); assert_eq!(skater.pose_generation, physics.ticks); - if phase.is_some_and(|phase| phase >= 215) { break; } + if phase.is_some_and(|phase| phase >= 215) { + break; + } } - assert!(sequence_start.is_some(), "Real pushing never attained stock slide speed: side={side}, speed={}, threshold={entry_speed}", physics.riding.motion.ground_speed); + assert!( + sequence_start.is_some(), + "Real pushing never attained stock slide speed: side={side}, speed={}, threshold={entry_speed}", + physics.riding.motion.ground_speed + ); assert!(saw_push, "No physical push was published"); - assert!(saw_start, "Diagonal input never started the stock slide latch: side={side}"); - assert!(saw_packet, "No canonical nonzero slide packet reached Skeleton: side={side}"); - assert!(saw_slide, "Stock graph never entered SlideGround: side={side}"); - assert!(release_clear, "Slide failed to exit after controller release: side={side}"); - eprintln!("Powerslide side={side}: stock entry, packet, physical SlideGround and release passed in {} ticks", physics.ticks); + assert!( + saw_start, + "Diagonal input never started the stock slide latch: side={side}" + ); + assert!( + saw_packet, + "No canonical nonzero slide packet reached Skeleton: side={side}" + ); + assert!( + saw_slide, + "Stock graph never entered SlideGround: side={side}" + ); + assert!( + release_clear, + "Slide failed to exit after controller release: side={side}" + ); + eprintln!( + "Powerslide side={side}: stock entry, packet, physical SlideGround and release passed in {} ticks", + physics.ticks + ); } diff --git a/crates/skate-game/src/tests/recorded_playback.rs b/crates/skate-game/src/tests/recorded_playback.rs new file mode 100644 index 00000000..623001a1 --- /dev/null +++ b/crates/skate-game/src/tests/recorded_playback.rs @@ -0,0 +1,92 @@ +//! User-captured packets through the production frame. The level, initial +//! pose and controller phase are clone-owned; this is not a stock parity oracle. +use super::*; + +#[test] +#[ignore = "requires private assets and SKATE3_INPUT_RECORDING"] +fn recorded_controller_sequence_runs_production_gameplay() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let path = std::env::var_os("SKATE3_INPUT_RECORDING").expect("set SKATE3_INPUT_RECORDING"); + let recording = + skate_data::input_recording::Recording::load(std::path::Path::new(&path)).unwrap(); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // User confirmed this recording was made on flat ground. Keep the same + // authored surface material and query metadata while removing the ramp. + physics.world = ground::flat_world(physics.world.triangles()[0].material); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut seconds = 0.0_f64; + let mut previous = None; + let mut airborne = 0; + let mut manual = 0; + let mut bail = 0; + let mut maximum_rider_com_y = f32::NEG_INFINITY; + while seconds * 1_000_000.0 <= recording.duration_us() as f64 { + let sample = recording.at_elapsed_us((seconds * 1_000_000.0) as u64); + assert!(sample.connected, "fixture disconnected at {}", sample.t_us); + input.sample_raw_for_test(sample.raw_state()); + let mut actions = input.player_actions(); + let dt = physics.settings.step.simulation.time_step; + controls.update( + &mut actions, + dt, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + controls.publish_gestures( + physics.animation_profile.physics_mode, + skater.player_input.physical.state.state_16, + ); + let result = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ); + let state = skater.player_state.current(); + let current = ( + state, + skater.animation.motion.flags.manualing, + skater.animation.motion.flags.doing_trick, + skater.animation.motion.animation.current_name.clone(), + skater.wipeout.state.count, + ); + if previous.as_ref() != Some(¤t) || result.is_err() { + let deck = physics.board.bodies()[6].rates; + eprintln!( + "RECORDING t={seconds:.4} sample={} state={current:?} p={:?} v={:?} balance={} jump={} rider_com={:?} rider_v={:?} pose_error={:?} bail_reasons={:?}", + sample.sample, + deck.position, + deck.linear_velocity, + skater.animation_input.fields.balance, + skater.animation_input.extra.jump_strength, + skater.skeleton.record.centre_of_mass, + skater.animated_skeleton.board_frames.com_velocity, + skater.collision_maximum_error, + skater.wipeout.state.reasons, + ); + previous = Some(current); + } + result.unwrap_or_else(|e| panic!("Recording t={seconds:.4} sample={}: {e}", sample.sample)); + airborne += u32::from(state.category() == 200); + manual += u32::from(skater.animation.motion.flags.manualing); + bail += u32::from(state.category() == 300); + maximum_rider_com_y = maximum_rider_com_y.max(skater.skeleton.record.centre_of_mass[1]); + assert_eq!(skater.pose_generation, physics.ticks); + assert!(camera.frame.is_some()); + seconds += f64::from(dt); + } + assert!(airborne > 0, "capture never reached an onboard air state"); + eprintln!( + "RECORDING completed: ticks={} air={airborne} manual={manual} bail={bail} max_rider_com_y={maximum_rider_com_y}; smoke check only, NOT stock parity", + physics.ticks + ); +} diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs index 2acbf607..04ed8db2 100644 --- a/crates/skate-game/src/tests/wipeout_playback.rs +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -1,17 +1,17 @@ //! Real Xbox bail chord through input, stock graphs, physical state and pose. use super::*; use skate_core::{input::xbox::XboxState, player::state::PhysicalStateId}; +#[path = "bail_geometry.rs"] +mod geometry; #[test] #[ignore = "requires private stock animation banks and collections"] -fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { +fn raw_controller_bail_runs_stock_wipeout_for_900_ticks() { let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = - GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal) - .unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); @@ -20,7 +20,8 @@ fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { let mut requested_at = None; let mut entered_at = None; let mut saw_action_request = false; - for tick in 0..600 { + let mut geometry = geometry::Geometry::default(); + for tick in 0..1200 { let request = settled_ticks >= 60 && requested_at.is_none(); if request { requested_at = Some(tick); @@ -64,6 +65,30 @@ fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { saw_action_request = true; } let state = skater.player_state.current(); + if state == PhysicalStateId::WipeoutGround { + geometry.observe(&physics, &skater, tick); + // WipeoutEnter82D3B5E8 releases both board drive channels; + // SetPhysicsState82DB8540/LetGo82D75440 releases possession. + // Finite transforms alone cannot detect a stale attachment. + assert_eq!( + physics.board.hook().drive.dynamics, + [0, 0, 0, 2, 0, 0, 0, 2], + "Deck still driven at bail tick{tick}" + ); + assert_eq!( + skater.board_possession.state.selected_hand_424, 2, + "Hand still owns released board at tick{tick}" + ); + for hand in &skater.board_possession.state.hands { + assert_eq!( + hand.dynamics, + [[0, 0, 0, 2]; 2], + "Hand/deck constraint survives bail tick{tick}" + ); + } + assert_eq!(physics.board.collision_group(), 7); + assert!(skater.skeleton_collision.is_ragdoll); + } if requested_at.is_none() { assert_ne!( state, @@ -94,10 +119,13 @@ fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { "Missing gameplay camera at tick{tick}" ); if let Some(entered) = entered_at { - if tick - entered >= 120 { + if tick - entered >= 900 { eprintln!( "Raw bail completed: request={requested_at:?}, state300={entered}, final_tick={tick}" ); + eprintln!( + "Post-solve bail geometry (meters/tick/joint; no parity tolerance): {geometry:?}" + ); return; } } @@ -110,7 +138,7 @@ fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { saw_action_request, "Raw chord never reached stock ActionGraph WipeOutRequest" ); - panic!("Did not complete120 ticks after state300: entered={entered_at:?}"); + panic!("Did not complete900 ticks after state300: entered={entered_at:?}"); } fn assert_finite(physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { @@ -171,24 +199,38 @@ fn assert_finite(physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { #[test] #[ignore = "requires private stock animation banks and collections"] fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { + checkpoint_reply(false); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn marker_reply_restores_on_foot() { + checkpoint_reply(true); +} + +fn checkpoint_reply(on_foot: bool) { let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); let root = std::path::Path::new(&root); let assets = skate_data::GameAssets::load(root).unwrap(); let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - let mut physics = - GamePhysics::load_with_difficulty(root, None, crate::difficulty::Difficulty::Normal) - .unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); let mut controls = PlayerControls::default(); let mut input = crate::input::ControllerInput::default(); let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); - let checkpoint = physics.board.bodies()[6].rates.position; + let mut checkpoint = physics.board.bodies()[6].rates.position; + let destination = if on_foot { PhysicalStateId::BipedGround } else { PhysicalStateId::PhysicsGround }; let mut saw_ready = false; let mut restored_ticks = 0; for tick in 0..160 { if tick == 60 { assert!((physics.board.bodies()[6].rates.position.z - checkpoint.z).abs() > 0.1); - skater.teleport_state.request_checkpoint(); + if on_foot { + let transform = skater.animated_skeleton.roots.animation_to_world; + checkpoint = skate_core::math::Vector3::new(transform[3][0], transform[3][1], transform[3][2]); + skater.teleport_state.request_manual(transform, false); + } + if !on_foot { skater.teleport_state.request_checkpoint(); } } input.sample_raw_for_test(XboxState { buttons: if (12..60).contains(&tick) { 0x1000 } else { 0 }, @@ -222,7 +264,7 @@ fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { .expect("702 must publish the captured reply"); assert_eq!( (output.next_state, output.state_61, output.board_272), - (100, 1, 1) + (if on_foot { 500 } else { 100 }, 1, 1) ); assert_eq!(f32::from_bits(output.transform[3][2]), checkpoint.z); saw_ready = true; @@ -237,94 +279,10 @@ fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { assert!((position.z - checkpoint.z).abs() < 0.05); assert!(!skater.skeleton_collision.is_ragdoll); } - if tick > 61 && skater.player_state.current() == PhysicalStateId::PhysicsGround { + if tick > 61 && skater.player_state.current() == destination { restored_ticks += 1; } assert_finite(&physics, &skater, tick); } assert!(saw_ready && restored_ticks > 60); } - -#[test] -#[ignore = "requires private stock animation banks and collections"] -fn runout_request_enters_stock_motion_graph_in_all_difficulties() { - offboard_stability(None); -} - -#[test] -#[ignore = "requires private stock assets and SKATE_MAP_TEST_PATH"] -fn imported_map_offboard_stability() { - let path = std::env::var_os("SKATE_MAP_TEST_PATH").expect("set SKATE_MAP_TEST_PATH"); - let map = skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)).unwrap(); - offboard_stability(Some(&map)); -} - -fn offboard_stability(map: Option<&skate_data::skate_map::SkateMap>) { - let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); - let root = std::path::Path::new(&root); - let assets = skate_data::GameAssets::load(root).unwrap(); - let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); - for difficulty in crate::difficulty::Difficulty::ALL { - for scenario in 0..3 { - let walking=scenario!=0; - let mut physics = GamePhysics::load_with_difficulty(root, map, difficulty).unwrap(); - let mut skater = - SkaterRuntime::load(root, &graphs, &physics, difficulty.key()).unwrap(); - let mut controls = PlayerControls::default(); - let mut input = crate::input::ControllerInput::default(); - let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); - let mut visited = std::collections::BTreeSet::new(); - for tick in 0..720 { - if tick == 120 && !walking { - skater.player_state.state_flags[78 - 52] = true; - } - // Inject the completed runout request at the physical -> animation - // boundary; leave stock graph selection and all following ticks live. - input.sample_raw_for_test(XboxState { - buttons: if walking && (tick == 120 || tick == 500) { - 0x8000 - } else if walking && tick == 240 { - 0x4000 - } else { - 0 - }, - triggers: if scenario==2&&(tick==180||tick==420) {[255,0]} else {[0; 2]}, - left: if walking && tick > 200 { - [0, 16000] - } else { - [0; 2] - }, - right: [0; 2], - }); - let mut actions = input.player_actions(); - controls.update( - &mut actions, - physics.settings.step.simulation.time_step, - physics.settings.input_magnitude_threshold, - skater.player_input.physical.scoring.capabilities_204, - ); - frame::advance( - &mut physics, - &mut skater, - &mut controls, - &graphs, - &mut actions, - true, - &mut camera, - ) - .unwrap_or_else(|error| { - panic!("Offboard {difficulty:?} scenario={scenario} tick{tick}: {error}") - }); - assert_finite(&physics, &skater, tick); - visited.insert(skater.player_state.current() as u32); - } - eprintln!("Stability {difficulty:?} walking={walking}: {visited:?}"); - if walking { - assert!( - visited.contains(&500), - "Dismount never reached the on-foot owner" - ); - } - } - } -}