diff --git a/crates/skate-core/src/player/offboard/controller/tests.rs b/crates/skate-core/src/player/offboard/controller/tests.rs index 58c7f9fe..d61037ee 100644 --- a/crates/skate-core/src/player/offboard/controller/tests.rs +++ b/crates/skate-core/src/player/offboard/controller/tests.rs @@ -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]); diff --git a/crates/skate-core/src/player/offboard/movement_velocity/math.rs b/crates/skate-core/src/player/offboard/movement_velocity/math.rs index 6be7e536..3d20a9b5 100644 --- a/crates/skate-core/src/player/offboard/movement_velocity/math.rs +++ b/crates/skate-core/src/player/offboard/movement_velocity/math.rs @@ -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); diff --git a/crates/skate-core/src/player/offboard/movement_velocity/tests.rs b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs index e0aa7a05..7f09e6fe 100644 --- a/crates/skate-core/src/player/offboard/movement_velocity/tests.rs +++ b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs @@ -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) } diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs index 433a67db..fdd7856b 100644 --- a/crates/skate-game/src/graph_host/action.rs +++ b/crates/skate-game/src/graph_host/action.rs @@ -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, const_handlers: Vec, time_handlers: Vec, + board_adjust: Vec, body_flip: Vec, body_flip_settings: Option, /// 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; diff --git a/crates/skate-game/src/graph_host/action_board_adjust.rs b/crates/skate-game/src/graph_host/action_board_adjust.rs new file mode 100644 index 00000000..e793f8b1 --- /dev/null +++ b/crates/skate-game/src/graph_host/action_board_adjust.rs @@ -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, angle: Option, 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)) + } +} diff --git a/crates/skate-game/src/graph_host/action_nodes.rs b/crates/skate-game/src/graph_host/action_nodes.rs index aca4f94e..4110bb38 100644 --- a/crates/skate-game/src/graph_host/action_nodes.rs +++ b/crates/skate-game/src/graph_host/action_nodes.rs @@ -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, } } } diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs index e2fd209c..0d361e43 100644 --- a/crates/skate-game/src/graph_host/mod.rs +++ b/crates/skate-game/src/graph_host/mod.rs @@ -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; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index 0110a7fd..717e15bd 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -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, pub wipeout_physical: Option, 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; diff --git a/crates/skate-game/src/graph_host/motion_execute_leaf.rs b/crates/skate-game/src/graph_host/motion_execute_leaf.rs index 104fe461..b4a08cff 100644 --- a/crates/skate-game/src/graph_host/motion_execute_leaf.rs +++ b/crates/skate-game/src/graph_host/motion_execute_leaf.rs @@ -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()); diff --git a/crates/skate-game/src/graph_host/motion_finger_flip.rs b/crates/skate-game/src/graph_host/motion_finger_flip.rs new file mode 100644 index 00000000..d7bc6213 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_finger_flip.rs @@ -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 { + // 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); + } +} diff --git a/crates/skate-game/src/graph_host/motion_instances.rs b/crates/skate-game/src/graph_host/motion_instances.rs index 724e36b6..916ede9a 100644 --- a/crates/skate-game/src/graph_host/motion_instances.rs +++ b/crates/skate-game/src/graph_host/motion_instances.rs @@ -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 }, 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 269f0e6a..34da0b0b 100644 --- a/crates/skate-game/src/graph_host/motion_stock_gameplay.rs +++ b/crates/skate-game/src/graph_host/motion_stock_gameplay.rs @@ -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, diff --git a/crates/skate-game/src/graph_host/tests/motion_board_adjust.rs b/crates/skate-game/src/graph_host/tests/motion_board_adjust.rs new file mode 100644 index 00000000..5f6cca59 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_board_adjust.rs @@ -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); +} diff --git a/crates/skate-game/src/graph_host/tests/motion_finger_flip.rs b/crates/skate-game/src/graph_host/tests/motion_finger_flip.rs new file mode 100644 index 00000000..89e3dee8 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_finger_flip.rs @@ -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"); +} diff --git a/crates/skate-game/src/tests/action_host.rs b/crates/skate-game/src/tests/action_host.rs index ae67b769..0afd18d9 100644 --- a/crates/skate-game/src/tests/action_host.rs +++ b/crates/skate-game/src/tests/action_host.rs @@ -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 { diff --git a/docs/board-adjust-native.md b/docs/board-adjust-native.md new file mode 100644 index 00000000..39df21f6 --- /dev/null +++ b/docs/board-adjust-native.md @@ -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. diff --git a/docs/crash-reports.md b/docs/crash-reports.md index ddbb544a..a9c22bb8 100644 --- a/docs/crash-reports.md +++ b/docs/crash-reports.md @@ -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: diff --git a/tools/build_verified_windows.ps1 b/tools/build_verified_windows.ps1 new file mode 100644 index 00000000..4ac15539 --- /dev/null +++ b/tools/build_verified_windows.ps1 @@ -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 +} diff --git a/tools/verify_windows_build_sources.ps1 b/tools/verify_windows_build_sources.ps1 new file mode 100644 index 00000000..9907d992 --- /dev/null +++ b/tools/verify_windows_build_sources.ps1 @@ -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"