mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Port complete native BoardAdjust lifecycle and angle wrap state
This commit is contained in:
1 parent
411260a15a
commit
f96be1dd10
7 files changed
+276
-18
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user