Fix FingerFlipOut crash with native grab exit curve lifecycle

This commit is contained in:
chazmm committed 2026-09-09 16:51:15 +01:00
1 parent 1a6e95b188
commit dff672aee4
8 files changed
+229 -2

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ pub(crate) mod motion_ground_slope;
pub(crate) mod motion_hand_services;
mod motion_hooks;
mod motion_hippy_jump;
mod motion_finger_flip;
pub(crate) mod motion_intent_filter;
mod motion_kickturn;
pub(crate) mod motion_landing;
@@ -114,6 +114,7 @@ pub struct MotionHost {
pub physical_stance: Option<(bool, bool)>,
pub flags: super::motion_landing::Flags,
hippy_jump: super::motion_hippy_jump::Settings,
finger_flip: super::motion_finger_flip::Settings,
pub landing_physical: Option<super::motion_landing::Physical>,
pub wipeout_physical: Option<super::motion_wipeout::Physical>,
pub wipeout_controls: super::motion_wipeout::Controls,
@@ -290,6 +291,7 @@ impl MotionHost {
kickturn: super::motion_kickturn::load_settings(data)?,
manual: super::motion_manual::settings(data)?,
hippy_jump: super::motion_hippy_jump::Settings::load(data)?,
finger_flip: super::motion_finger_flip::Settings::load(data)?,
next_instance: 1,
})
}
@@ -424,3 +426,7 @@ 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;
@@ -290,6 +290,23 @@ pub(super) fn execute(
// matching conditions; the authored node itself must still
// advance that lifecycle instead of aborting graph execution.
match operation {
crate::graph_host::motion_stock_gameplay::Operation::FingerFlipOut { grab_intent } => {
let Instance::FingerFlipOut(state) = instance else {
return Err("FingerFlipOut operation/instance mismatch".into());
};
if phase == 0 {
state.begin();
} else if phase == 1 {
let value = state.update(
host.animation.motion_intents.contains_key(&grab_intent),
frame.dt, &host.finger_flip,
);
host.animation.set_attribute(SettableAttribute {
name: encode(b"Grabbing"), value,
normalized: false, sequence_id: -1,
});
}
}
crate::graph_host::motion_stock_gameplay::Operation::MatchAirTime => {
let Instance::MatchAirTime(state) = instance else {
return Err("MatchAirTime operation/instance mismatch".into());
@@ -0,0 +1,81 @@
//! FingerFlipOut: TU3 Begin82BB3198, Update82BB7C40, empty End82B61BB8.
use skate_core::point_graph::PointGraph;
use skate_data::collections::Collections;
pub(super) struct Settings {
minimum: f32,
maximum: f32,
curve: PointGraph<8>,
}
impl Settings {
pub(super) fn load(data: &Collections) -> Result<Self, String> {
// GlobalAttributeCollection164 is selected at8289FD10..30. The
// anim_motion schema places this PointNegGraphData8 field at190.
// Preserve numeric identities because the retail vault omits names.
let words = data.words::<20>(
"anim_motion", "Hash_41DB0C4F82003A15", "Hash_E0C1407B688858AD",
)?;
let minimum = f32::from_bits(words[0]);
let maximum = f32::from_bits(words[2]);
if !minimum.is_finite() || !maximum.is_finite() || minimum > maximum {
return Err("FingerFlipOut has invalid stock timer bounds".into());
}
Ok(Self {
minimum,
maximum,
curve: PointGraph {
x: std::array::from_fn(|i| f32::from_bits(words[4 + i])),
y: std::array::from_fn(|i| f32::from_bits(words[12 + i])),
},
})
}
}
#[derive(Default)]
pub(super) struct State {
elapsed: f32,
}
impl State {
pub(super) fn begin(&mut self) {
self.elapsed = 0.0;
}
pub(super) fn update(&mut self, grab_present: bool, dt: f32, settings: &Settings) -> f32 {
//82454FD8 tests membership, including a zero-valued intent. The time
//service supplies dt; this is not the magnitude of the grab input.
self.elapsed = if grab_present { self.elapsed - dt } else { self.elapsed + dt };
self.elapsed = self.elapsed.clamp(settings.minimum, settings.maximum);
settings.curve.evaluate(self.elapsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finger_flip_release_regrab_bounds_and_reentry() {
let settings = Settings {
minimum: 0.0,
maximum: 0.5,
curve: PointGraph {
x: std::array::from_fn(|i| i as f32 / 14.0),
y: std::array::from_fn(|i| 1.0 - i as f32 / 7.0),
},
};
let mut state = State::default();
state.begin();
assert_eq!(state.update(true, 0.25, &settings), 1.0);
assert!((state.update(false, 0.125, &settings) - 0.75).abs() < 1e-6);
assert!((state.update(false, 0.125, &settings) - 0.5).abs() < 1e-6);
assert!((state.update(true, 0.125, &settings) - 0.75).abs() < 1e-6);
assert_eq!(state.update(false, 1.0, &settings), 0.0);
assert_eq!(state.update(false, 1.0, &settings), 0.0);
assert_eq!(state.elapsed, 0.5);
state.begin();
assert_eq!(state.elapsed, 0.0);
assert_eq!(state.update(true, 0.25, &settings), 1.0);
}
}
@@ -31,6 +31,7 @@ pub(super) enum Instance {
Shove(super::super::motion_shove::ShoveState),
SetManualAngle(skate_core::animation::manual::State),
HippyJumpAntic(super::super::motion_hippy_jump::State),
FingerFlipOut(super::super::motion_finger_flip::State),
///82BB5930 initializes byte8 once per allocated behavior instance.
JumpInto { first_update: bool },
}
@@ -95,6 +96,9 @@ impl Instance {
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::HippyJumpAntic,
) => Self::HippyJumpAntic(Default::default()),
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::FingerFlipOut { .. },
) => Self::FingerFlipOut(Default::default()),
MotionOperation::StockGameplay(
super::super::motion_stock_gameplay::Operation::JumpInto { .. },
) => Self::JumpInto { first_update: true },
@@ -146,7 +146,7 @@ pub enum Operation {
attribute_y: AttributeName,
},
FootPlantAbsorb,
FingerFlipOut,
FingerFlipOut { grab_intent: String },
EnterSkitchingBehaviour,
SkitchingBehaviour,
SkitchShimmyingBehaviour,
@@ -217,7 +217,10 @@ impl Operation {
attribute_x: encode(b"tweak_x"),
attribute_y: encode(b"tweak_y"),
}, "FootPlantAbsorb" => Self::FootPlantAbsorb,
"FingerFlipOut" => Self::FingerFlipOut,
"FingerFlipOut" => Self::FingerFlipOut {
//Factory82BC7C18 binds authored grabintent and literal Grabbing.
grab_intent: required_text(a, "grabintent"),
},
"EnterSkitchingBehaviour" => Self::EnterSkitchingBehaviour,
"SkitchingBehaviour" => Self::SkitchingBehaviour,
"SkitchShimmyingBehaviour" => Self::SkitchShimmyingBehaviour,
@@ -0,0 +1,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");
}
+50
View File
@@ -12,6 +12,56 @@ 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.
## 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: