Merge gameplay crash fixes and native board adjustment

This commit is contained in:
chazmm committed 2026-09-09 19:25:48 +01:00
commit 2611f9ccc4
19 files changed
+700 -21

No files matched your search

@@ -86,6 +86,20 @@ fn constructor_uses_clip_metrics_and_original_initial_fields() {
assert_eq!(s.motion.target_scale_784, -1.0);
assert_eq!(s.cadence.phase.duration, None);
}
#[test]
fn tiny_velocity_delta_keeps_ground_publication_finite() {
let mut controller = Controller::new(settings(), metrics());
controller.state.motion.velocity_480 = [0.0, 1.0e-21, 1.0, 0.0];
controller.state.motion.speed_704 = 1.0;
let result = controller.step_ground(&job());
assert!(result.position.into_iter().all(f32::is_finite));
assert!(result.velocity.into_iter().all(f32::is_finite));
assert!(result.physical_frame.into_iter().flatten().all(f32::is_finite));
assert!(result.animation_frame.into_iter().flatten().all(f32::is_finite));
assert!(controller.state.surface.lean.into_iter().all(f32::is_finite));
assert!(controller.state.cadence.phase.phase.is_finite());
}
#[test]
fn absent_clip_queries_have_original_zero_speed() {
let s = State::new([None; 3]);
@@ -10,13 +10,22 @@ pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) }
pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) }
pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) }
fn inverse_squared(value: f32) -> f32 {
// The portable estimate preserves subnormals. For a tiny positive squared
// length, r*r can overflow even though both the length and inverse root
// are finite. Normalize its exponent before the recovered refinements;
// rsqrt(x * 2^24) * 2^12 = rsqrt(x). Normal inputs keep their old path.
let (value, rescale) = if value > 0.0 && value.is_subnormal() {
(value * 16_777_216.0, 4096.0)
} else {
(value, 1.0)
};
//PC seed is not bit-exact Xenon; retain both original refinement stages.
let mut r = crate::physics::reciprocal_sqrt::estimate(value);
for _ in 0..2 {
let error = (-value).mul_add(r * r, 1.0);
r = (r * 0.5).mul_add(error, r);
}
r
r * rescale
}
pub(super) fn length(v: Vector) -> f32 {
let squared = dot(v, v);
@@ -1,4 +1,49 @@
use super::*;
#[test]
fn tiny_velocity_delta_remains_finite_and_reaches_target() {
// Flat-ground convergence can leave a tiny vertical correction while X/Z
// already match. Its squared length is positive but subnormal.
let current = [-1.6481168, 1.0e-21, 5.7692037, 0.0];
let target = [-1.6481168, 0.0, 5.7692037, 0.0];
let delta = sub(target, current);
assert!(dot(delta, delta).is_subnormal());
let result = limit_delta(target, current, 0.2);
assert_eq!(result, target);
assert!(length(delta).is_finite());
assert!((length(delta) / 1.0e-21 - 1.0).abs() < 0.001);
}
#[test]
fn tiny_velocity_refinement_boundaries_preserve_limits_and_invalid_inputs() {
for exponent in -74..=-60 {
let component = 2.0f32.powi(exponent);
let delta = [component, 0.0, 0.0, 0.0];
let measured = length(delta);
assert!(measured.is_finite() && measured > 0.0);
assert!((measured / component - 1.0).abs() < 1e-6);
let maximum = component * 0.5;
let limited = limit_delta(delta, [0.0; 4], maximum);
assert!(limited.into_iter().all(f32::is_finite));
assert!((limited[0] / maximum - 1.0).abs() < 1e-6);
}
assert_eq!(length([0.0; 4]), 0.0);
assert!(length([f32::NAN, 0.0, 0.0, 0.0]).is_nan());
assert!(limit_delta([f32::NAN, 0.0, 0.0, 0.0], [0.0; 4], 0.2)[0].is_nan());
}
#[test]
fn tiny_velocity_correction_does_not_poison_walking_state() {
let mut state = State { velocity: [0.0, 1.0e-21, 5.0, 0.0], speed: 5.0, ..State::default() };
let mut input = input();
input.steering = 0.0;
for _ in 0..120 {
state.update(&settings(), &input);
assert_eq!(state.velocity, [0.0, 0.0, 5.0, 0.0]);
assert_eq!(state.speed, 5.0);
assert!(state.forward_delta.is_finite() && state.right_delta.is_finite());
}
}
fn curve(value: f32) -> PointGraph<8> { PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; 8] } }
fn settings() -> Settings {
Settings { slope_speed_scalar: curve(1.0), slope_mode_speed: curve(3.0), turn_vs_speed: curve(90.0), turn_delta_vs_speed: curve(15.0) }
+23 -17
View File
@@ -12,7 +12,8 @@ use skate_core::graph::{
};
use skate_core::input::body_flip_signal;
use skate_core::input::graph_intents::{CreateMgIntent, IntentMutation};
use skate_core::input::graph_intents::apply_filter;
#[path = "action_board_adjust.rs"]
mod board_adjust;
use skate_data::collections::Collections;
use super::outputs::{ActionGraphInput, ActionGraphOutput, GraphCapabilityReport, GraphDiagnostics};
@@ -44,6 +45,7 @@ pub struct ActionHost {
created: Vec<bool>,
const_handlers: Vec<ConstMgIntent>,
time_handlers: Vec<TimeMgIntent>,
board_adjust: Vec<board_adjust::State>,
body_flip: Vec<body_flip_signal::State>,
body_flip_settings: Option<body_flip_signal::Settings>,
/// JuiceHook instance+8 starts with the native null intent name. Its
@@ -148,6 +150,7 @@ impl ActionHost {
created: vec![false; count],
const_handlers,
time_handlers,
board_adjust: vec![board_adjust::State::default(); count],
body_flip: vec![body_flip_signal::State::default(); count],
body_flip_settings: None,
juice_pending: vec![String::new(); count],
@@ -201,7 +204,7 @@ impl ActionHost {
}
}
fn publish_board_adjust(&mut self, instance: &ActionInstance) {
fn publish_board_adjust(&mut self, behavior: BehaviorId, instance: &ActionInstance) {
let Some(magnitude_name) = instance.config.mg_intent_mag.as_deref() else {
return;
};
@@ -209,21 +212,16 @@ impl ActionHost {
return;
};
if let Some(value) = self.action_intents.get("BoardAdjustMag").copied() {
self.motion_intents.insert(magnitude_name, value);
if let Some((magnitude, angle)) = self.board_adjust[behavior].update(
self.action_intents.get("BoardAdjustMag").copied(),
self.action_intents.get("BoardAdjustAngle").copied(),
instance.config.angle_filter, instance.config.negate_on_mirror,
self.stance.is_some_and(|(_, mirrored)| mirrored),
) {
self.motion_intents.insert(magnitude_name, magnitude);
self.motion_intents.insert(angle_name, angle);
} else {
self.motion_intents.remove(magnitude_name);
}
if let Some(mut value) = self.action_intents.get("BoardAdjustAngle").copied() {
if instance.config.angle_filter != 0 {
value = apply_filter(value, instance.config.angle_filter);
}
if instance.config.negate_on_mirror && self.stance.is_some_and(|(_, mirrored)| mirrored) {
value = -value;
}
self.motion_intents.insert(angle_name, value);
} else {
self.motion_intents.remove(angle_name);
}
}
@@ -317,7 +315,7 @@ impl Host for ActionHost {
return;
}
if let ActionOperation::BoardAdjust = instance.operation {
self.publish_board_adjust(&instance);
self.board_adjust[behavior].begin();
return;
}
if let ActionOperation::CreateConstMgIntent = instance.operation {
@@ -391,7 +389,7 @@ impl Host for ActionHost {
return;
}
if let ActionOperation::BoardAdjust = instance.operation {
self.publish_board_adjust(&instance);
self.publish_board_adjust(behavior, &instance);
return;
}
if let ActionOperation::CreateConstMgIntent = instance.operation {
@@ -429,6 +427,14 @@ impl Host for ActionHost {
let Some(instance) = self.operation(behavior).cloned() else {
return;
};
if matches!(instance.operation, ActionOperation::BoardAdjust) {
//82BA2EB8: remove both authored names; End does not reset state.
for name in [instance.config.mg_intent_mag.as_deref(),
instance.config.mg_intent_angle.as_deref()].into_iter().flatten() {
self.motion_intents.remove(name);
}
return;
}
if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) {
self.trick_handlers[behavior].end(&mut self.motion_intents);
return;
@@ -0,0 +1,39 @@
//! TU3 BoardAdjust instance+8/+12, Begin82BA2BA8, Update82BA2BC8.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct State {
wrap: u32,
previous_angle: f32,
}
impl State {
pub fn begin(&mut self) { *self = Self::default(); }
pub fn update(&mut self, magnitude: Option<f32>, angle: Option<f32>, filter: u32,
negate_on_mirror: bool, mirrored: bool) -> Option<(f32, f32)> {
let (Some(magnitude), Some(mut angle)) = (magnitude, angle) else {
//82BA2E88: remove both outputs and reset both instance fields.
self.begin();
return None;
};
if filter <= 7 { angle = skate_core::input::graph_intents::apply_filter(angle, filter); }
if negate_on_mirror && mirrored { angle *= -1.0; }
//82BA2D70: crossing the +/-pi seam retains the previous side until
//the raw angle crosses back. Test the previous RAW angle, not output.
let crossed = self.previous_angle * angle < 0.0
&& self.previous_angle.abs() > f32::from_bits(0x3fc90fdb);
match self.wrap {
0 if crossed && self.previous_angle < 0.0 => self.wrap = 1,
0 if crossed && self.previous_angle > 0.0 => self.wrap = 2,
1 if crossed && self.previous_angle > 0.0 => self.wrap = 0,
2 if crossed && self.previous_angle < 0.0 => self.wrap = 0,
_ => {}
}
self.previous_angle = angle;
let output = match self.wrap {
1 => f32::from_bits(0xc0490fdb), //822F8908
2 => f32::from_bits(0x40490fdb), //82060C44
_ => angle,
};
Some((magnitude, output))
}
}
@@ -52,7 +52,8 @@ impl Parameter {
filter(attributes.text("mirrorFilter")),
],
angle_filter: filter(attributes.text("angleFilter")),
negate_on_mirror: attributes.boolean_byte("negateOnMirror", 0) != 0,
//BoardAdjust82BA2A90 passes true as the constructor default.
negate_on_mirror: attributes.boolean_byte("negateOnMirror", 1) != 0,
}
}
}
+1
View File
@@ -30,6 +30,7 @@ pub(crate) mod motion_ground_slope;
pub(crate) mod motion_hand_services;
mod motion_hooks;
mod motion_hippy_jump;
mod motion_finger_flip;
pub(crate) mod motion_intent_filter;
mod motion_kickturn;
pub(crate) mod motion_landing;
@@ -114,6 +114,7 @@ pub struct MotionHost {
pub physical_stance: Option<(bool, bool)>,
pub flags: super::motion_landing::Flags,
hippy_jump: super::motion_hippy_jump::Settings,
finger_flip: super::motion_finger_flip::Settings,
pub landing_physical: Option<super::motion_landing::Physical>,
pub wipeout_physical: Option<super::motion_wipeout::Physical>,
pub wipeout_controls: super::motion_wipeout::Controls,
@@ -290,6 +291,7 @@ impl MotionHost {
kickturn: super::motion_kickturn::load_settings(data)?,
manual: super::motion_manual::settings(data)?,
hippy_jump: super::motion_hippy_jump::Settings::load(data)?,
finger_flip: super::motion_finger_flip::Settings::load(data)?,
next_instance: 1,
})
}
@@ -424,3 +426,11 @@ mod jump_into_tests;
#[cfg(test)]
#[path = "tests/motion_deck_angles.rs"]
mod deck_angles_tests;
#[cfg(test)]
#[path = "tests/motion_finger_flip.rs"]
mod finger_flip_tests;
#[cfg(test)]
#[path = "tests/motion_board_adjust.rs"]
mod board_adjust_tests;
@@ -290,6 +290,23 @@ pub(super) fn execute(
// 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::FingerFlipOut { grab_intent } => {
let Instance::FingerFlipOut(state) = instance else {
return Err("FingerFlipOut operation/instance mismatch".into());
};
if phase == 0 {
state.begin();
} else if phase == 1 {
let value = state.update(
host.animation.motion_intents.contains_key(&grab_intent),
frame.dt, &host.finger_flip,
);
host.animation.set_attribute(SettableAttribute {
name: encode(b"Grabbing"), value,
normalized: false, sequence_id: -1,
});
}
}
crate::graph_host::motion_stock_gameplay::Operation::MatchAirTime => {
let Instance::MatchAirTime(state) = instance else {
return Err("MatchAirTime operation/instance mismatch".into());
@@ -0,0 +1,81 @@
//! FingerFlipOut: TU3 Begin82BB3198, Update82BB7C40, empty End82B61BB8.
use skate_core::point_graph::PointGraph;
use skate_data::collections::Collections;
pub(super) struct Settings {
minimum: f32,
maximum: f32,
curve: PointGraph<8>,
}
impl Settings {
pub(super) fn load(data: &Collections) -> Result<Self, String> {
// GlobalAttributeCollection164 is selected at8289FD10..30. The
// anim_motion schema places this PointNegGraphData8 field at190.
// Preserve numeric identities because the retail vault omits names.
let words = data.words::<20>(
"anim_motion", "Hash_41DB0C4F82003A15", "Hash_E0C1407B688858AD",
)?;
let minimum = f32::from_bits(words[0]);
let maximum = f32::from_bits(words[2]);
if !minimum.is_finite() || !maximum.is_finite() || minimum > maximum {
return Err("FingerFlipOut has invalid stock timer bounds".into());
}
Ok(Self {
minimum,
maximum,
curve: 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 {
elapsed: f32,
}
impl State {
pub(super) fn begin(&mut self) {
self.elapsed = 0.0;
}
pub(super) fn update(&mut self, grab_present: bool, dt: f32, settings: &Settings) -> f32 {
//82454FD8 tests membership, including a zero-valued intent. The time
//service supplies dt; this is not the magnitude of the grab input.
self.elapsed = if grab_present { self.elapsed - dt } else { self.elapsed + dt };
self.elapsed = self.elapsed.clamp(settings.minimum, settings.maximum);
settings.curve.evaluate(self.elapsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finger_flip_release_regrab_bounds_and_reentry() {
let settings = Settings {
minimum: 0.0,
maximum: 0.5,
curve: PointGraph {
x: std::array::from_fn(|i| i as f32 / 14.0),
y: std::array::from_fn(|i| 1.0 - i as f32 / 7.0),
},
};
let mut state = State::default();
state.begin();
assert_eq!(state.update(true, 0.25, &settings), 1.0);
assert!((state.update(false, 0.125, &settings) - 0.75).abs() < 1e-6);
assert!((state.update(false, 0.125, &settings) - 0.5).abs() < 1e-6);
assert!((state.update(true, 0.125, &settings) - 0.75).abs() < 1e-6);
assert_eq!(state.update(false, 1.0, &settings), 0.0);
assert_eq!(state.update(false, 1.0, &settings), 0.0);
assert_eq!(state.elapsed, 0.5);
state.begin();
assert_eq!(state.elapsed, 0.0);
assert_eq!(state.update(true, 0.25, &settings), 1.0);
}
}
@@ -31,6 +31,7 @@ pub(super) enum Instance {
Shove(super::super::motion_shove::ShoveState),
SetManualAngle(skate_core::animation::manual::State),
HippyJumpAntic(super::super::motion_hippy_jump::State),
FingerFlipOut(super::super::motion_finger_flip::State),
///82BB5930 initializes byte8 once per allocated behavior instance.
JumpInto { first_update: bool },
}
@@ -95,6 +96,9 @@ impl Instance {
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::HippyJumpAntic,
) => Self::HippyJumpAntic(Default::default()),
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::FingerFlipOut { .. },
) => Self::FingerFlipOut(Default::default()),
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::JumpInto { .. },
) => Self::JumpInto { first_update: true },
@@ -146,7 +146,7 @@ pub enum Operation {
attribute_y: AttributeName,
},
FootPlantAbsorb,
FingerFlipOut,
FingerFlipOut { grab_intent: String },
EnterSkitchingBehaviour,
SkitchingBehaviour,
SkitchShimmyingBehaviour,
@@ -217,7 +217,10 @@ impl Operation {
attribute_x: encode(b"tweak_x"),
attribute_y: encode(b"tweak_y"),
}, "FootPlantAbsorb" => Self::FootPlantAbsorb,
"FingerFlipOut" => Self::FingerFlipOut,
"FingerFlipOut" => Self::FingerFlipOut {
//Factory82BC7C18 binds authored grabintent and literal Grabbing.
grab_intent: required_text(a, "grabintent"),
},
"EnterSkitchingBehaviour" => Self::EnterSkitchingBehaviour,
"SkitchingBehaviour" => Self::SkitchingBehaviour,
"SkitchShimmyingBehaviour" => Self::SkitchShimmyingBehaviour,
@@ -0,0 +1,71 @@
use super::*;
#[test]
#[ignore = "requires private stock graphs and animation banks"]
fn board_adjust_stock_release_keeps_the_authored_air_blend() {
use crate::graph_runtime::CompiledGraph;
use skate_data::state_graph::{StateGraph, binding::Binding};
use skate_core::animation::playback_tree::{Evaluation, PoseCommand};
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 find = |predicate: &dyn Fn(&MotionOperation) -> bool| {
host.remap.behaviors.iter().position(|&op| predicate(&host.operations[op])).unwrap()
};
let adjust = find(&|op| matches!(op, MotionOperation::Play(p) if p.animation == "BLEND_ADJUST_RIGHT"));
let air = find(&|op| matches!(op, MotionOperation::Play(p) if p.animation == "B_AIR_CYC" && p.transition.kind == 2 && p.transition.under == 1));
let filters = ["RightBoardAdjustMag", "RightBoardAdjustAngle"].map(|name|
find(&|op| matches!(op, MotionOperation::IntentFilter(p) if p.intent == name && p.settings.blend_out == Some(0.08))));
let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: vec![] };
for id in filters { host.allocate(id, &frame); host.execute(id, &frame, 0).unwrap(); }
host.allocate(adjust, &frame);
host.execute(adjust, &frame, 0).unwrap();
for _ in 0..60 {
host.animation.advance(frame.dt, 0.0);
host.animation.motion_intents.insert("RightBoardAdjustMag", 0.9);
host.animation.motion_intents.insert("RightBoardAdjustAngle", 0.6);
for id in filters { host.execute(id, &frame, 1).unwrap(); }
host.execute(adjust, &frame, 1).unwrap();
host.animation.apply_parameters().unwrap();
}
host.animation.motion_intents.remove("RightBoardAdjustMag");
host.animation.motion_intents.remove("RightBoardAdjustAngle");
let mut reached_exit = false;
for _ in 0..120 {
host.animation.advance(frame.dt, 0.0);
for id in filters { host.execute(id, &frame, 1).unwrap(); }
host.execute(adjust, &frame, 1).unwrap();
host.animation.apply_parameters().unwrap();
if ["BoardAdjustMag", "BoardAdjustAngle"].iter().all(|name|
host.animation.filtered_intents.get(name).unwrap().abs() < 0.4) {
reached_exit = true;
break;
}
}
assert!(reached_exit);
host.animation.refresh_tree_attributes().unwrap();
host.execute(adjust, &frame, 2).unwrap();
for id in filters { host.execute(id, &frame, 2).unwrap(); }
host.allocate(air, &frame);
host.execute(air, &frame, 0).unwrap();
host.animation.apply_parameters().unwrap();
let evaluation = Evaluation { cull_threshold: 0.0, update_history: false };
let commands = host.animation.evaluate_pose(evaluation).unwrap();
assert!(matches!(commands.last(), Some(PoseCommand::Blend { weight }) if *weight == 0.0), "new air tree must not replace the outgoing pose: {commands:?}");
let mut previous = 0.0;
for _ in 0..12 {
host.animation.advance(frame.dt, 0.0);
let commands = host.animation.evaluate_pose(evaluation).unwrap();
let Some(PoseCommand::Blend { weight }) = commands.last() else { panic!("missing authored 0.2s blend") };
assert!(*weight >= previous && *weight <= 1.0);
previous = *weight;
}
assert!(previous > 0.999);
}
@@ -0,0 +1,65 @@
use super::*;
#[test]
#[ignore = "requires private stock graphs and animation banks"]
fn stock_finger_flip_crash_node_3080_lifecycle_and_intent_source() {
use crate::graph_runtime::CompiledGraph;
use skate_data::state_graph::{StateGraph, binding::Binding};
use super::super::motion_stock_gameplay::Operation;
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();
let behavior = 3080;
let MotionOperation::StockGameplay(Operation::FingerFlipOut { grab_intent }) =
&host.operations[host.remap.behaviors[behavior]] else {
panic!("The reported stock behavior must bind FingerFlipOut");
};
let intent = grab_intent.clone();
assert!(!intent.is_empty() && !intent.contains('$'), "authored intent must be resolved");
let frame = Frame { dt: 0.125, current: None, last: None, state_times: vec![] };
host.allocate(behavior, &frame);
host.execute(behavior, &frame, 0).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), None);
// Raw AG input cannot substitute for the MG intent read by virtual16.
host.action_intents.insert(&intent, 1.0);
host.execute(behavior, &frame, 1).unwrap();
let released = host.animation.pending_parameter(encode(b"Grabbing")).unwrap();
assert!((0.0..1.0).contains(&released));
// Native membership test treats zero as present and reverses the timer.
host.animation.motion_intents.insert(&intent, 0.0);
host.execute(behavior, &frame, 1).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(1.0));
host.animation.motion_intents.remove(&intent);
for _ in 0..6 { host.execute(behavior, &frame, 1).unwrap(); }
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
host.execute(behavior, &frame, 2).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
// Reentry resets private time, but Begin/End do not publish a parameter.
host.execute(behavior, &frame, 0).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
host.execute(behavior, &frame, 1).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(released));
let second = host.remap.behaviors.iter().enumerate().find_map(|(id, &op)| {
(id != behavior && matches!(host.operations[op],
MotionOperation::StockGameplay(Operation::FingerFlipOut { .. }))).then_some(id)
}).expect("stock graph has separate finger-flip exit nodes");
host.allocate(second, &frame);
host.execute(second, &frame, 0).unwrap();
host.execute(second, &frame, 1).unwrap();
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(released));
host.execute(behavior, &frame, 1).unwrap();
assert!(host.animation.pending_parameter(encode(b"Grabbing")).unwrap() < released);
assert!(host.animation.motion_attributes.is_empty(), "writes a tree parameter, not a physics packet");
}
@@ -52,6 +52,77 @@ fn gesture_behavior_publishes_and_releases_the_stock_tre_flip_intent() {
assert!(host.errors.is_empty(), "{:?}", host.errors);
}
#[test]
fn board_adjust_native_wrap_lifecycle_and_release() {
let mut config = parameter("", 0.0);
config.mg_intent = None;
config.mg_intent_mag = Some("RightBoardAdjustMag".into());
config.mg_intent_angle = Some("RightBoardAdjustAngle".into());
config.negate_on_mirror = true;
let mut host = ActionHost::new(ActionInstances::new(vec![ActionInstance {
operation: ActionOperation::BoardAdjust, config, parameters: vec![],
}]), OperationRemap { behaviors: vec![0, 0], conditions: vec![], hooks: vec![] });
let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: vec![] };
let pi = f32::from_bits(0x40490fdb);
for mirrored in [false, true] {
host.stance = Some((false, mirrored));
Host::begin(&mut host, 0, [0; 6], &frame);
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
host.action_intents.insert("BoardAdjustMag", 0.9);
// Expected outputs from82BA2D70..2E80: latch at seam, hold,
// unlatch crossing back; then exercise the opposite seam direction.
for (raw, expected) in [(3.0, 3.0), (-3.0, pi), (-2.0, pi),
(2.0, 2.0), (0.0, 0.0), (-3.0, -3.0), (3.0, -pi),
(2.0, -pi), (-2.0, -2.0)] {
host.action_intents.insert("BoardAdjustAngle", raw);
Host::update(&mut host, 0, [0; 6], &frame);
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"),
Some(&(if mirrored { -expected } else { expected })));
}
// Another instance has its own seam state, even for the same config.
Host::begin(&mut host, 1, [0; 6], &frame);
host.action_intents.insert("BoardAdjustAngle", 3.0);
Host::update(&mut host, 1, [0; 6], &frame);
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"), Some(&(if mirrored { -3.0 } else { 3.0 })));
// One missing member removes the PAIR and resets the latch.
host.action_intents.remove("BoardAdjustMag");
Host::update(&mut host, 0, [0; 6], &frame);
assert!(!host.motion_intents.contains_key("RightBoardAdjustMag"));
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
host.action_intents.insert("BoardAdjustMag", 0.0); // present zero is valid
Host::update(&mut host, 0, [0; 6], &frame);
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"), Some(&(if mirrored { -3.0 } else { 3.0 })));
host.motion_intents.insert("Unrelated", 0.75);
Host::end(&mut host, 0, [0; 6], &frame);
assert!(!host.motion_intents.contains_key("RightBoardAdjustMag"));
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
assert_eq!(host.motion_intents.get("Unrelated"), Some(&0.75));
}
}
#[test]
#[ignore = "requires private stock graph"]
fn board_adjust_stock_constructor_defaults_and_overrides() {
use crate::graph_runtime::{CompiledGraph, LoadedGraph};
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/ActionGraph_OnBoard.stategraph")).unwrap();
let binding = Binding::from_graph(&source).unwrap();
let runtime = CompiledGraph::from_binding(&binding).unwrap();
let graph = LoadedGraph { source, binding, runtime };
let host = ActionHost::from_graph(&graph, &skate_data::collections::Collections::load(&root).unwrap()).unwrap();
let nodes: Vec<_> = host.instances.operations.iter().filter(|op| matches!(op.operation, ActionOperation::BoardAdjust)).collect();
assert!(!nodes.is_empty());
for direction in ["Left", "Right", "Up", "Down"] {
assert!(nodes.iter().any(|op| op.config.mg_intent_mag.as_deref() == Some(format!("{direction}BoardAdjustMag").as_str())));
}
assert!(nodes.iter().any(|op| op.config.negate_on_mirror));
assert!(nodes.iter().any(|op| !op.config.negate_on_mirror));
// Right.Regular omits negateOnMirror; Right.Switch explicitly disables it.
assert!(nodes.iter().any(|op| op.config.angle_filter == 6 && op.config.mg_intent_mag.as_deref() == Some("RightBoardAdjustMag") && op.config.negate_on_mirror));
assert!(nodes.iter().any(|op| op.config.angle_filter == 6 && op.config.mg_intent_mag.as_deref() == Some("LeftBoardAdjustMag") && !op.config.negate_on_mirror));
}
#[test]
fn const_callbacks_publish_then_remove_motion_intent() {
let instance = ActionInstance {
+66
View File
@@ -0,0 +1,66 @@
# BoardAdjust native lifecycle and release
The cleanup-only change `fd280fd` was reverted in `411260a` after a user
reported a snap during return to neutral. This replacement ports the complete
BoardAdjust lifecycle, rather than changing animation timing to hide the snap.
## Reference and observed behavior
Read-only sources under `Documents/Skate3Research/research/reverse-engineering`:
- `generated/skate3_animation_research_recomp.71.cpp`, SHA-256
`a4b47427402cfc4fc6aa0a6e5b08d4fe81b974da669c107e610a960ec2652257`.
- `XexDump/default_82000000_011B0000.bin`, SHA-256
`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`.
- The same guest instructions decoded with IDA 9.3 in a disposable database.
- Stock ActionGraph BoardAdjust includes, `MotionGraphIncludes/air.xml`, and
`MotionGraphIncludes/BoardAdjusts/T_BoardAdjust.xml` from `MiscloadState`.
Addresses below are TU3 guest virtual addresses (image base `0x82000000`), not
addresses in the Windows reference executable. The reference installation was
located at `C:/CLEAN/Skate3CustomEngineLayer-0.1.0-preview.18-Windows`;
its `skate3.exe` SHA-256 is
`4d5234b2297fa7f1e02808ce2780ad37c960e4da6d26ae5004591cba91fe6904`.
It was not launched or modified; no runtime parity claim is made for that build.
Observed in recompilation and corroborated by IDA:
| Callback | Guest address | Behavior |
|---|---|---|
| Constructor | `82BA2A30` | Magnitude name at object+28, angle name at +52, angle filter at +76, mirror-negation default **true** at +80. |
| Allocate | `82BA2F48` | Separate 16-byte instance for each behavior. |
| Begin | `82BA2BA8` | Clear instance wrap mode at +8 and previous raw angle at +12. No intent publication. |
| Update | `82BA2BC8` | Require both source intents; copy magnitude, filter angle, optionally negate for mirrored stance, apply persistent angle seam logic. |
| Missing input | `82BA2E88` | If either source is missing, remove both outputs and clear both instance fields. Present zero remains a valid value. |
| End | `82BA2EB8` | Remove both authored outputs; no instance reset here. |
The seam logic at `82BA2D70` detects a sign change when the previous raw angle
has absolute value greater than pi/2 (`822538BC`, bits `3FC90FDB`). In mode 0,
crossing from negative enters mode 1, crossing from positive enters mode 2.
Mode 1 holds -pi (`822F8908`, bits `C0490FDB`), mode 2 holds +pi
(`82060C44`, bits `40490FDB`). Crossing back from the opposite side clears
the mode. Every successful update stores the **raw filtered angle**, even while
the published angle is held at pi. This state was missing from the Rust host.
## Release and animation handoff
The stock graph filters missing axes toward zero with blendOut 0.08,
clampAcc 0.03, clampVel 0.2. Recompilation `82BB17D0` agrees with the existing
filter arithmetic, including the missing-input branch. The board-adjust state
exits when the directional intent is absent and both absolute filtered values
are below 0.4. The default airborne tree is `B_AIR_CYC` with authored time 0.2
and transitionUnder enabled. Blend evaluation/weight functions `82B961F8` and
`82B963D8` agree with the existing blend progression. No duration, threshold,
filter coefficient, pose offset, or extra smoothing has been fitted or changed.
The new tests cover the native seam branches, mirror signs, separate instances,
entry/update/exit, partial input loss, present zero, stock constructor defaults,
and the stock filtered release followed by its 0.2-second animation-tree blend.
These are source-level tests, not a replay of the user's controller sequence.
The reported visual snap's precise cause remains unconfirmed until gameplay
validation; the additional native discrepancies are independently established.
Validation (Windows MSVC release/static CRT): seven action-host tests passed,
including stock graph parsing; the stock release-to-air animation test passed.
That test observes a zero-weight incoming tree at handoff and the authored blend
progression to full weight. It does not measure rendered bone-pose continuity.
+117
View File
@@ -12,6 +12,123 @@ Coverage limits: a Windows unhandled-exception filter attempts a fixed, allocati
For manual UI validation, run the executable with `--crash-report-preview`. This generates a clearly synthetic report and opens the popup without initializing the game, setup, Steam or assets. Check Copy report, clipboard text, Open folder, saved UTF-8 text and Close. Static tests do not validate desktop presentation.
## Mixed-worktree dependency in f96be1d (2026-09-09)
The DownTown report at tick1020 repeats the nonfinite walking result before
the swept-line rejection. The full local stderr retains the finite GroundJob
and tiny surface components omitted from the bounded report.
The matching shipped PDB establishes that `movement_velocity/math.rs` was
compiled from worktree `8186`, with SHA-256
`4652072df4a51a6bf8eb2dd837e61d4fb6e01710d984334af8073deb4c4449ac`.
That source lacks the earlier subnormal refinement fix. The intended source
in this worktree has SHA-256
`76de0780adb5ea015c57fa4118105669f1897ef01eafb611eed14cc829c5eda1`.
The game build ID identified the game crate, but did not establish the identity
of reused path dependencies. Matching EXE/PDB GUIDs alone did not catch this.
`tools/build_verified_windows.ps1` now builds into a private
`target/verified-windows` under the current worktree. Its verification step
reads PDB source checksums and requires both current-worktree paths and matching
SHA-256 for referenced project crates and vendored Bevy sources. It fails closed
when required game/core/data source records are missing. The old shipped PDB
fails this check (619 mismatched path/checksum entries).
Use this build script for subsequent crash-test builds and retain its
`source-checks.json` with the staged EXE/PDB and existing import/hash manifest.
Do not share Cargo artifact directories between independently edited worktrees.
This repair changes build provenance, not gameplay rules or collision validation.
## Walking velocity convergence (2026-09-09)
Observed user failure: clean revision `dff672a`, build timestamp
`1788969088174193000`, report timestamp `1788969335`. At tick4059 in
BipedGround on University, `OFFBOARD_GROUND_RESULT_NONFINITE` precedes
`Invalid offboard swept-line request`. The job has finite spatial inputs;
the controller publishes NaN velocity/position, a NaN right delta and a
forward delta of approximately `-3.11e-43`. The collision query then rejects
NaN XYZ coordinates. Its validation is correct and remains intact.
The walking velocity limiter's portable reciprocal-square-root refinement
formed `r*r` from a positive subnormal squared length. A finite correction
of `1e-21` produces squared bits `0x000002CA` and a finite inverse root near
`9.997e20`, whose square overflows to infinity. The two refinements then
produce NaN. A focused pre-fix regression reproduced an all-NaN limited
velocity from otherwise finite vectors. This establishes the numerical
failure independently of the collision system; it is not a replay of all
4059 input ticks.
The fix normalizes only positive subnormal squared lengths by the exact
power of two `2^24`, retains both refinement stages, and rescales the inverse
root by `2^12`. It preserves the ordinary-input path, movement limits and
invalid-input propagation. No global floating-point mode changes, collision
bypass, arbitrary epsilon, or replacement player state are involved. This
is a stability correction for the existing portable PC arithmetic, not a
claim of bit-exact Xenon instruction emulation.
Regressions cover the failing finite vectors, powers-of-two across the
subnormal/normal boundary, limiting tiny corrections, steady walking updates,
and the complete ground-controller publication (including position, lean and
cadence). Zero and NaN checks ensure invalid inputs are not silently repaired.
The report, matching stderr and arithmetic evidence are retained privately
with the test builds. Gameplay validation remains with the user.
Validation: the finite-delta regression failed before the arithmetic change
with `[NaN, NaN, NaN, NaN]`. Afterward all 11 movement-velocity tests passed,
followed by all 202 offboard library tests in the release MSVC configuration
(`cargo test --release --locked --target x86_64-pc-windows-msvc -p skate-core
--lib player::offboard::`). No game or renderer was launched.
## FingerFlipOut (2026-09-09)
Observed user failure: clean revision `1a6e95b`, build timestamp
`1788967727931108700`, report timestamp `1788968141`, University fingerprint
`c911af6099e02b65`. At tick3467 in KnownAir, MotionGraph behavior3080 returned
`MotionGraph stock gameplay producer FingerFlipOut is not implemented`.
This was an explicit unsupported-operation exit, with no recorded panic or
native exception. The matching launch stderr contains the same error.
Native recovery from the TU3 image mapped at `0x82000000`:
- Factory82BC7C18 binds authored `grabintent` and literal `Grabbing`.
Vtable8232072C allocates a private float through82BBCB50, calls
Begin82BB3198, Update82BB7C40 and empty End82B61BB8.
- Begin sets the float to zero. Update reads the MotionGraph intent map
through Actor20/virtual16. Membership lookup82454FD8 counts a zero-valued
intent as present. Presence subtracts the time-service delta; absence adds
it. This does not read raw ActionGraph or filtered tweak input.
- Update clamps the retained time to layout190/198, evaluates eight curve
keys at1A0 and values at1C0 through PointGraph82481E10, then writes
`Grabbing` through ISkaterAnim80 (unnormalized, sequence -1).
- Global constructor8289FD10..30 selects collection `41DB0C4F82003A15` at
offset164. Helper8289D4E8 identifies class `anim_motion`. The retail schema
independently maps layout190 to field `E0C1407B688858AD`, type
`Sk8::PointNegGraphData8`. Its stock timer bounds are 0 and 0.5 seconds,
with a descending grab blend from 1 to 0. Numeric names remain supported
directly; no shared asset edit or invented curve is required.
The implementation retains one timer per bound behavior and publishes only
on Update. Begin resets time; End leaves the last parameter alone. Other
unimplemented operations still report errors.
Evidence identities (SHA-256): TU3 mapped image
`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`;
tested stock MotionGraph
`806b2665e435be4e313d70a06fab4538e0adf60a2dfd3e80e82ae8d0b4a2e7c1`.
Bounded decoded instructions, schema lookup and the original report are
retained privately with this test build. IDA changes were confined to a
disposable database copy. The instruction/data mapping is observed;
successful gameplay reproduction after the fix remains user validation.
Focused regressions cover release/regrab timing, clamping, entry reset,
the actual stock behavior3080, zero-valued intent membership, raw-input
separation, independent behavior instances and Begin/End publication rules.
They do not launch the renderer or replay the user's full input sequence.
Validation: `cargo test --release --locked --target x86_64-pc-windows-msvc
-p skate-game --bin skate3rust --no-default-features finger_flip --
--include-ignored` passed both tests against the prepared assets (0.18 seconds).
## SetDeckPitchAndYaw (2026-09-08)
The supplied report stopped in KnownAir at tick4813, MotionGraph behavior3373:
+19
View File
@@ -0,0 +1,19 @@
param([Parameter(Mandatory=$true)][string]$OutputDirectory)
$ErrorActionPreference = 'Stop'
$sourceRoot = Split-Path -Parent $PSScriptRoot
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path -LiteralPath $outputPath) { throw "Build output already exists: $outputPath" }
New-Item -ItemType Directory -Path $outputPath | Out-Null
# Never share path-dependency artifacts between concurrently edited worktrees.
$targetPath = Join-Path $sourceRoot 'target/verified-windows'
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
Push-Location $sourceRoot
try {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static'
cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $targetPath -p skate-game --bin skate3rust --no-default-features -- -C extra-filename= -o "$outputPath/skate3rust.exe" -C "link-arg=/PDB:$outputPath/skate3rust.pdb" *> "$outputPath/compile.log"
if ($LASTEXITCODE -ne 0) { Get-Content "$outputPath/compile.log" -Tail 40; throw 'Game build failed' }
& "$PSScriptRoot/verify_windows_build_sources.ps1" -Pdb "$outputPath/skate3rust.pdb" -SourceRoot $sourceRoot -ReportPath "$outputPath/source-checks.json"
} finally {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags
Pop-Location
}
+40
View File
@@ -0,0 +1,40 @@
param(
[Parameter(Mandatory=$true)][string]$Pdb,
[string]$SourceRoot = (Split-Path -Parent $PSScriptRoot),
[string]$ReportPath
)
$ErrorActionPreference = 'Stop'
$sourcePrefix = [IO.Path]::GetFullPath($SourceRoot).TrimEnd('\', '/') + '\'
$pdbutil = Join-Path $env:ProgramFiles 'LLVM/bin/llvm-pdbutil.exe'
$dump = & $pdbutil dump --files $Pdb
if ($LASTEXITCODE -ne 0) { throw 'PDB source inspection failed' }
$checked = @{}
$failures = [Collections.Generic.List[string]]::new()
foreach ($line in $dump) {
if ($line -notmatch '^- \(SHA-256: ([0-9A-Fa-f]{64})\) (.+)$') { continue }
$compiledHash = $Matches[1]
$compiledPath = $Matches[2].Replace('/', '\')
if ($compiledPath -notmatch '\\((?:crates\\skate-[^\\]+|vendor\\bevy_[^\\]+)\\.+)$') { continue }
$relative = $Matches[1]
$key = "$compiledPath|$compiledHash"
if ($checked.ContainsKey($key)) { continue }
$expectedPath = Join-Path $sourcePrefix $relative
$sourceHash = if (Test-Path -LiteralPath $expectedPath -PathType Leaf) {
(Get-FileHash -LiteralPath $expectedPath -Algorithm SHA256).Hash
} else { $null }
$matchesRoot = $compiledPath.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)
$matchesHash = $sourceHash -eq $compiledHash
$checked[$key] = [ordered]@{ file=$relative; compiledPath=$compiledPath; compiledSha256=$compiledHash; sourceSha256=$sourceHash; matchesRoot=$matchesRoot; matchesHash=$matchesHash }
if (-not $matchesRoot -or -not $matchesHash) { $failures.Add($relative) }
}
foreach ($crate in @('skate-core', 'skate-data', 'skate-game')) {
if (-not ($checked.Values | Where-Object { $_.file.StartsWith("crates\$crate\") })) {
$failures.Add("No source checksums found for $crate")
}
}
if ($ReportPath) {
[ordered]@{ pdb=[IO.Path]::GetFullPath($Pdb); sourceRoot=$sourcePrefix; passed=($failures.Count -eq 0); files=@($checked.Values | Sort-Object file,compiledPath) } |
ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ReportPath -Encoding utf8
}
if ($failures.Count) { throw "PDB source provenance failed for $($failures.Count) entries: $(($failures | Select-Object -First 8) -join ', ')" }
Write-Output "Verified $($checked.Count) workspace source checksums and paths in $Pdb"