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..470ec565 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,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; 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_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/docs/crash-reports.md b/docs/crash-reports.md index ddbb544a..815f65c0 100644 --- a/docs/crash-reports.md +++ b/docs/crash-reports.md @@ -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: