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/motion.rs b/crates/skate-game/src/graph_host/motion.rs index 470ec565..717e15bd 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -430,3 +430,7 @@ 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/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/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.