Port complete native BoardAdjust lifecycle and angle wrap state

This commit is contained in:
chazmm committed 2026-09-09 17:37:03 +01:00
1 parent 411260a15a
commit f96be1dd10
7 files changed
+276 -18

No files matched your search

+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,
}
}
}
@@ -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;
@@ -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);
}
@@ -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.