From 620414a487230028957e21b933e3105eb0c7a15e Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:56:31 +0100 Subject: [PATCH 1/2] Port native bail checkpoint selection and document integration gaps --- crates/skate-core/src/player/mod.rs | 1 + crates/skate-core/src/player/respawn.rs | 236 ++++++++++++++++++ crates/skate-core/src/player/tests/respawn.rs | 126 ++++++++++ docs/native-bail-recovery.md | 143 +++++++++++ 4 files changed, 506 insertions(+) create mode 100644 crates/skate-core/src/player/respawn.rs create mode 100644 crates/skate-core/src/player/tests/respawn.rs create mode 100644 docs/native-bail-recovery.md diff --git a/crates/skate-core/src/player/mod.rs b/crates/skate-core/src/player/mod.rs index b6e3c738..80139f86 100644 --- a/crates/skate-core/src/player/mod.rs +++ b/crates/skate-core/src/player/mod.rs @@ -22,3 +22,4 @@ pub mod offboard; pub mod conditioner_capabilities; pub mod teleport_state; +pub mod respawn; diff --git a/crates/skate-core/src/player/respawn.rs b/crates/skate-core/src/player/respawn.rs new file mode 100644 index 00000000..e0d23605 --- /dev/null +++ b/crates/skate-core/src/player/respawn.rs @@ -0,0 +1,236 @@ +//! TU3 actor checkpoint history (82BFB068..82BFCB38). +//! Scene predicates are supplied by the host; absence is never acceptance. +use crate::physics::skeleton_animation_record::{AnimationPartTransform as Matrix, IDENTITY}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Candidate { + pub transform: Matrix, + pub stance: u32, + pub offboard: bool, + pub score: f32, +} + +/// Separate native predicates: history selection does not repeat ground or edge +/// validation, whereas current-position selection does (82BFC038/82BFC378). +pub trait Validation { + type Error; + fn ground(&mut self, transform: &Matrix) -> Result, Self::Error>; + fn location(&mut self, transform: &Matrix) -> Result; + fn occupants(&mut self, transform: &Matrix) -> Result; + fn edges(&mut self, transform: &Matrix) -> Result; +} + +#[derive(Clone, Copy, Debug)] +pub struct Ground { + pub position: [f32; 4], + pub offboard: bool, +} + +///Completed native packet fields. The caller must provide actual producer +///values, especially Ground323 and conditioner164; neither means grounded. +pub struct Observation { + pub measurements: i32, + pub root_position: [f32; 4], //Skeleton416, unmirrored11920 translation + pub com_position: [f32; 4], //Skeleton64, physical COM16144 + pub teleport_requested: bool, //State69 + pub physical_state: u32, //State16 + pub state_frames: u32, //State4 + pub ground_suppressed: bool, //Ground323 + pub offboard_correction: bool, //OffBoard333 + pub ground_category: u32, //Collision16 low16 + pub foot_categories: [u32; 2], //OffBoard52/56 packed categories + pub riding_transform: Matrix, //82591E30(false) + pub offboard_transform: Matrix, //82591E30(true) + pub stance: u32, //82592B68 + pub alternate_world: bool, //SimController virtual0 +} + +pub struct History { + initial: Candidate, + entries: Vec, // native33 slots reserve one sentinel:32 entries + cooldown: i32, + pub current_orientation: Matrix, //208; only eligible riding states write it + pub current_position: [f32; 4], //272; every actor update writes Skeleton64 +} +impl History { + pub fn new(initial: Candidate) -> Self { + Self { + initial, + entries: vec![initial], + cooldown: 20, + current_orientation: IDENTITY, + current_position: initial.transform[3], + } + } + + ///82BFB1E0/82BFB3F8. Do not call this with last-grounded observations; + ///the native actor consumes one completed physical publication per update. + pub fn observe( + &mut self, + input: &Observation, + minimum_state_frames: u32, + scene: &mut V, + ) -> Result<(), V::Error> { + self.current_position = input.com_position; + if !self.recording_due(input.measurements, input.root_position) || input.teleport_requested + { + return Ok(()); + } + let (transform, offboard, score) = match input.physical_state { + 100 | 104 => { + //This write precedes both the15-frame gate and terrain checks. + self.current_orientation = input.riding_transform; + if input.state_frames <= minimum_state_frames + || input.ground_suppressed + || !surface_allowed(input.ground_category) + { + return Ok(()); + } + ( + input.riding_transform, + input.ground_category == 8, + surface_score(input.ground_category), + ) + } + 500 | 502 => { + if input.state_frames <= minimum_state_frames || input.offboard_correction { + return Ok(()); + } + ( + input.offboard_transform, + true, + surface_score(input.foot_categories[0]) + .max(surface_score(input.foot_categories[1])), + ) + } + _ => return Ok(()), + }; + if !scene.location(&transform)? || !scene.edges(&transform)? { + return Ok(()); + } + if !input.alternate_world && scene.ground(&transform)?.is_none() { + return Ok(()); + } + //The recording path discards the ray's offboard byte and hit position. + self.insert(Candidate { + transform, + stance: input.stance, + offboard, + score, + }); + Ok(()) + } + + ///82BFB1E0: call even when the physical state cannot supply a candidate. + ///The120 gate uses conditioner164 (EmpiricalMeasurements82DE5858), not + ///State4's time in the current state. Its reset lifecycle is caller-owned. + pub fn recording_due(&mut self, measurements: i32, root_position: [f32; 4]) -> bool { + let mut eligible = measurements >= 120; + if !eligible { + self.cooldown = 0; + } + if self.cooldown > 0 { + self.cooldown -= 1; + eligible = false; + } + eligible + && !self + .entries + .iter() + .rev() + .any(|entry| distance_squared(entry.transform[3], root_position) < 2.25) + } + + ///82BFCB38. Construction and successful recording both refresh cooldown. + pub fn insert(&mut self, candidate: Candidate) { + if self.entries.len() == 32 { + self.entries.remove(0); + } + self.entries.push(candidate); + self.cooldown = 20; + } + + ///82BFC550: descending score, newest wins ties; selected entry is removed + ///before validation, including successful selections. Repeated bails thus + ///cannot keep selecting the same history entry. + fn pop_best(&mut self) -> Option { + let mut best = None; + let mut score = -99990.0; + for (age, index) in (0..self.entries.len()).rev().enumerate() { + let mut value = self.entries[index].score; + if age < 1 { + value -= 100.0; + } + if age > 5 { + value -= 200.0; + } + if value > score { + best = Some(index); + score = value; + } + } + best.map(|index| self.entries.remove(index)) + } + + ///82BFC9B0, used by automatic actor reset82592518. Manual/session-marker + ///selection82BFC828 has a different history walk and must not call this. + pub fn automatic( + &mut self, + stance: u32, + scene: &mut V, + ) -> Result { + let mut transform = self.current_orientation; + transform[3] = self.current_position; + if let Some(ground) = scene.ground(&transform)? { + if scene.location(&transform)? + && scene.occupants(&transform)? + && scene.edges(&transform)? + { + if ground.offboard { + transform[3][1] = ground.position[1]; + } + return Ok(Candidate { + transform, + stance, + offboard: ground.offboard, + score: 0.0, + }); + } + } + while let Some(candidate) = self.pop_best() { + if scene.location(&candidate.transform)? && scene.occupants(&candidate.transform)? { + return Ok(candidate); + } + } + //82BFC718 also serves non-destructive selectors; retain its actual rule. + Ok(self.entries.last().copied().unwrap_or(Candidate { + transform: self.initial.transform, + stance, + offboard: false, + score: 0.0, + })) + } +} + +///82BFB3F8 rejects these categories; category8 explicitly requests offboard. +pub fn surface_allowed(category: u32) -> bool { + !matches!(category, 5 | 6 | 9 | 12 | 13) +} + +///Jump table82BFB698: category1/2=100,3/4=50,11=20, all others=0. +pub fn surface_score(category: u32) -> f32 { + match category { + 1 | 2 => 100.0, + 3 | 4 => 50.0, + 11 => 20.0, + _ => 0.0, + } +} + +fn distance_squared(a: [f32; 4], b: [f32; 4]) -> f32 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2) +} + +#[cfg(test)] +#[path = "tests/respawn.rs"] +mod tests; diff --git a/crates/skate-core/src/player/tests/respawn.rs b/crates/skate-core/src/player/tests/respawn.rs new file mode 100644 index 00000000..a75f9d21 --- /dev/null +++ b/crates/skate-core/src/player/tests/respawn.rs @@ -0,0 +1,126 @@ +use super::*; +struct Scene { + current: bool, + rejected_x: Option, + calls: Vec<&'static str>, +} +impl Validation for Scene { + type Error = (); + fn ground(&mut self, _: &Matrix) -> Result, ()> { + self.calls.push("ground"); + Ok(self.current.then_some(Ground { + position: [8., 2., 9., 0.], + offboard: true, + })) + } + fn location(&mut self, m: &Matrix) -> Result { + self.calls.push("location"); + Ok(self.rejected_x != Some(m[3][0])) + } + fn occupants(&mut self, _: &Matrix) -> Result { + self.calls.push("occupants"); + Ok(true) + } + fn edges(&mut self, _: &Matrix) -> Result { + self.calls.push("edges"); + Ok(true) + } +} +fn candidate(x: f32, score: f32) -> Candidate { + let mut transform = IDENTITY; + transform[3][0] = x; + Candidate { + transform, + stance: 1, + offboard: false, + score, + } +} +fn scene() -> Scene { + Scene { + current: false, + rejected_x: None, + calls: vec![], + } +} + +#[test] +fn scored_selection_consumes_entries_and_does_not_recheck_terrain() { + let mut h = History::new(candidate(-10., 0.)); + h.insert(candidate(10., 100.)); + h.insert(candidate(20., 100.)); + let mut s = scene(); + assert_eq!(h.automatic(0, &mut s).unwrap().transform[3][0], 10.); + assert_eq!(s.calls, ["ground", "location", "occupants"]); + //Latest is now penalized; initial0 ties its adjusted100-100, newer wins. + assert_eq!(h.automatic(0, &mut s).unwrap().transform[3][0], 20.); + assert_eq!(h.automatic(0, &mut s).unwrap().transform[3][0], -10.); + assert_eq!(h.automatic(0, &mut s).unwrap().transform[3][0], -10.); +} + +#[test] +fn rejection_removes_candidate_and_recomputes_age() { + let mut h = History::new(candidate(-10., 0.)); + h.insert(candidate(10., 100.)); + h.insert(candidate(20., 50.)); + let mut s = scene(); + s.rejected_x = Some(10.); + assert_eq!(h.automatic(1, &mut s).unwrap().transform[3][0], -10.); + assert_eq!(h.entries.len(), 1); + assert_eq!(h.entries[0].transform[3][0], 20.); +} + +#[test] +fn current_candidate_keeps_heading_and_horizontal_position_and_history() { + let mut h = History::new(candidate(-10., 0.)); + h.current_position = [3., 4., 5., 0.]; + h.current_orientation[2] = [1., 0., 0., 0.]; + let mut s = scene(); + s.current = true; + let c = h.automatic(7, &mut s).unwrap(); + assert_eq!(c.transform[3], [3., 2., 5., 0.]); + assert_eq!(c.transform[2], [1., 0., 0., 0.]); + assert_eq!((c.stance, c.offboard), (7, true)); + assert_eq!(h.entries.len(), 1); + assert_eq!(s.calls, ["ground", "location", "occupants", "edges"]); +} + +#[test] +fn insertion_capacity_and_native_gate_boundaries() { + let mut h = History::new(candidate(0., 0.)); + assert!(!h.recording_due(119, [10., 0., 0., 0.])); + assert!(h.recording_due(120, [1.5, 0., 0., 0.])); + assert!(!h.recording_due(120, [1.499, 0., 0., 0.])); + h.insert(candidate(10., 0.)); + for _ in 0..20 { + assert!(!h.recording_due(120, [1000., 0., 0., 0.])); + } + assert!(h.recording_due(120, [1000., 0., 0., 0.])); + for n in 0..40 { + h.insert(candidate(n as f32, 0.)); + } + assert_eq!(h.entries.len(), 32); + assert_eq!(h.entries[0].transform[3][0], 8.); + assert_eq!(h.initial.transform[3][0], 0.); +} + +#[test] +fn category_dispatch_matches_decrementing_ppc_branch_table() { + let accepted: Vec<_> = (0..=15).filter(|&x| surface_allowed(x)).collect(); + assert_eq!(accepted, [0, 1, 2, 3, 4, 7, 8, 10, 11, 14, 15]); + assert_eq!( + (0..=12).map(surface_score).collect::>(), + [0., 100., 100., 50., 50., 0., 0., 0., 0., 0., 0., 20., 0.] + ); +} + +#[test] +fn exhausted_history_fallback_uses_current_actor_stance() { + let mut h = History::new(candidate(-10., 100.)); + let mut s = scene(); + s.rejected_x = Some(-10.); + let c = h.automatic(0, &mut s).unwrap(); + assert_eq!(c.transform, h.initial.transform); + assert_eq!((c.stance, c.offboard, c.score), (0, false, 0.)); + assert!(h.entries.is_empty()); +} diff --git a/docs/native-bail-recovery.md b/docs/native-bail-recovery.md new file mode 100644 index 00000000..08fe00e2 --- /dev/null +++ b/docs/native-bail-recovery.md @@ -0,0 +1,143 @@ +# Native bail checkpoint investigation + +Status: **selector port only; gameplay integration is incomplete**. The existing +fixed-spawn response in `physics/teleport_state.rs` remains active. This change +must not be described as a working native bail-respawn fix. + +## Reference and scope + +Reference: TU3 memory image `default_82000000_011B0000.bin`, mapped at +`0x82000000`, SHA256 +`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. +Addresses below were inspected in the matching generated PPC instruction +comments; constants were checked against the raw image. The read-only IDA +lookup for `82BFB1E0` found no defined function in the locally available dump +database, so it did not provide independent decompiler confirmation. + +No game, recomp, controller harness, screenshot capture or asset-check launch +was performed. No binary/data files belong in this commit. + +## Implemented core + +`skate-core/src/player/respawn.rs` contains the actor history and ordinary +automatic selector, with required scene-validation callbacks. It has no host +default that silently accepts missing geometry or occupant providers. + +- `82BFB068` constructs the manager, seeds the history and starts a 20-update + cooldown. The native 33-slot ring has 32 usable entries. +- `82BFB1E0` publishes current COM position every actor tick. Recording needs + at least 120 empirical measurements, expired cooldown and a root at least + 1.5 units from every recorded position. Falling below 120 clears cooldown. +- `82BFB3F8` rejects State69. Riding states 100/104 publish the current + orientation before testing state age, Ground323 and surface eligibility. + Offboard states 500/502 require the state-age gate and no OffBoard333 + correction. Both paths require strictly more than the configured 15 frames. +- Surface categories 5, 6, 9, 12 and 13 are rejected for riding/ground checks. + Category 8 requests offboard recovery. Scores are 100 for categories 1/2, + 50 for 3/4, 20 for 11, and zero otherwise. +- Recording checks location, edges, then ground unless alternate-world mode + bypasses ground. It stores the original candidate, discarding the ray hit's + position and offboard result. +- `82BFC9B0` tries current position (`82BFC038`), scored history (`82BFC378`), + then fallback (`82BFC718`). Current selection checks ground, location, + occupants and edges, in that order. An offboard ray result replaces only Y. +- `82BFC550` ranks newest to oldest: newest gets a 100-point penalty; entries + older than five get a 200-point penalty. Strict comparisons make newest win + ties. Selection removes the entry before validation, including successful + entries. History validation repeats location/occupant checks only. +- Exhaustion falls back to initial transform, **current actor stance**, on-board + mode and score zero. Manual/session-marker selector `82BFC828` has a different + non-destructive history walk and must remain separate. + +The six compile-only unit cases cover consumption/validation order, rejection +and reranking, current-position Y correction, cooldown/distance/capacity +boundaries, surface categories, and exhausted-history stance. They have not +been executed. This is a scalar Rust translation, not demonstrated bit-exact +PPC floating-point equivalence. + +## Required host bindings + +| Native input | Producer / current Rust connection | +| --- | --- | +| State20 | Conditioner164, incremented by `82DE5858`, reset by `82DE5588`; **not** frames in current physical state. No equivalent empirical counter is currently published. | +| State4 / State16 / State69 | Published physical state age, ID and teleport request. | +| Skeleton64 | Physical skeleton COM16144, published by `82BE1AE8`; corresponds to physical reckoning vector64. | +| Skeleton416 | Translation of Skeleton368, the unmirrored animation-to-world11920 matrix; corresponds to `animated_skeleton.roots.animation_to_world`. | +| Ground323 | `82DB6EC0` reads PhysicalPlayer1872 (GrindManager), word460 bit27. Constructor `82D8A318` places its 416-byte result at +48; the relevant result word is +412. The live Rust grind owner has no published equivalent. Its actual set/clear producer still needs recovery. Do not substitute `active`, `grounded`, or constant false. | +| OffBoard333 | Biped708, the offboard contact-correction active flag. | +| OffBoard52/56 | Both are populated from Processed2596 in `82DB6EC0`; category is packed bits7..11. Do not use Processed2600 for the second field. | + +`82591E30` builds the recording transform. Riding reads the actor's board +frame through `82592A00` and adds 0.2 Y. Offboard uses Skeleton432 (mirrored +variant from `82BE3650`). If deck velocity squared exceeds 0.25, it normalizes +the velocity, constructs a world-up basis, and accepts that basis only when +the projected forward length squared exceeds 0.9. The exact host board-frame +binding and floating-point operation ordering still need completion. + +`82BFBC18` traces from candidate position +0.1 Y with delta [0,-10,0], using +query `82E0AAD0` with actor identity. It checks normal Y, ray drop and category, +then tests the vertical capsule above the **ray start**. `82BFB6F8` queries +authored edges in a [0.3,0.6,0.3] half-bound, with the native provider order and +40-edge capacity, and rejects squared segment distance below 0.09. Triangle +diagonals are not a replacement for authored edge data. + +`82BFB928` occupant predicates come from the LivingWorldManager provider +(SimController +212), not simply static collision geometry. `82BFBB48` +conditionally invokes the alternate-world location provider. The host adapter +must explicitly account for the actual available providers and world mode; +these predicates have not been connected or proven equivalent. + +## Stock settings found + +The installed collection data already includes class `Hash_12B64C0E804B0853`, +key `default`. These are read from data, not new tuning defaults: + +| Field hash | Value | Use | +| --- | --- | --- | +| C0526C883AF0ECCA | 0.4 | Capsule height | +| CEB092E418A5B001 | 0.5 | Capsule radius | +| 8ABE098D3806D273 | 1.0 | Maximum ray drop | +| ADD032CACF6A1C15 | 0.75 | Minimum normal Y | +| E64C980EE2114070 | 1.0 | Occupant query radius | +| 59E1F2B3CDC4E658 | 2.0 | Occupant query radius | +| 10B7C3A9CC8D3721 | 15 (Int32) | Minimum state age | + +Source `skatercollections.vlt` SHA256: +`3b7dbd062bb1c906a085514355afff35cfa22f486ae70820c5ad1a42a7aab25b`. + +## Stance and deferred reset dependency + +Actor reset `82592518` chooses the candidate, then calls `82592C08` to save +stance before queuing the reset reply (`825926F8`). Setter `82B97350` writes +animator15200; it does not immediately overwrite relative stance15196. +Getter `82592B68` combines natural15188, relative15196 and the fakie flag. + +MotionGraph `ResetToGivenStance` invokes `82B97308` / `82B972A8`, consuming +saved15200, updating flags15180 and relative15196, then clearing saved15200. +The current game handler instead only clears the posture request. Saved15200 +is absent from the canonical animation owner. The existing pure +`player/offboard/reset_animation.rs` helper and `motion_offboard/reset.rs` +owner contract are not integrated into this dispatch. + +`ResetSkaterAnimation` also needs selective native owner resets: the existing +`MotionAnimation::reset_from_stock` replaces all animation flags with zero, +where `82B98050` applies a selective mask. Connecting saved checkpoint stance +without completing this lifecycle would not preserve native recovery. + +Keep State702's request -> next actor input reply -> State702 output -> physical +reset ordering. Preserve the existing wipeout timers and recovery countdown. +Do not reset bodies from the selector. Map replacement should create a fresh +history; ordinary automatic recovery should retain the consumed history. + +## Validation and remaining work + +The Windows release game build passed with the requested static CRT flags. +After the fallback correction, the core library and six new cases compiled +with `cargo test --release --locked --target x86_64-pc-windows-msvc -p skate-core +--lib --no-run`. No test executable was run. + +Remaining: recover the grind flag producer; complete canonical stance/reset +ownership; bind transforms and empirical counter; implement native scene +predicates; connect observation and actor callback at their publication +boundaries; compile the integrated executable. Gameplay parity remains for +user testing after those dependencies are completed. From 43593efee8666dad462cfcf40caf0ad4fa59d3c1 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:13:18 +0100 Subject: [PATCH 2/2] Connect native bail checkpoints and deferred stance recovery --- .../src/graph_host/motion_animation.rs | 24 +- .../src/graph_host/motion_execute.rs | 13 +- .../src/graph_host/tests/motion_slide.rs | 26 ++ crates/skate-game/src/physics.rs | 1 + crates/skate-game/src/physics/frame.rs | 6 +- crates/skate-game/src/physics/grind.rs | 20 +- .../src/physics/offboard/contact_queries.rs | 2 +- crates/skate-game/src/physics/respawn.rs | 315 ++++++++++++++++++ crates/skate-game/src/physics/skater.rs | 3 + .../skate-game/src/physics/teleport_state.rs | 14 +- crates/skate-game/src/skater_animation.rs | 17 + docs/native-bail-recovery.md | 226 ++++++------- 12 files changed, 533 insertions(+), 134 deletions(-) create mode 100644 crates/skate-game/src/physics/respawn.rs diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs index 56c3c908..5f5fce87 100644 --- a/crates/skate-game/src/graph_host/motion_animation.rs +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -34,6 +34,11 @@ pub struct MotionAnimation { ///Actual fullSkaterAnim15180 flags. AddBindPose consumes bits22/21 on ///construction; the owner publishes the updated flags back to the actor. pub skater_animation_flags: Option, + /// SkaterAnim15188/15196/15200 at the graph publication boundary. + pub natural_stance: u32, + pub relative_stance: u32, + pub requested_stance: u32, + pub reset_action_intents: bool, attribute_mirror: std::sync::Arc, settable: SettableAttributes, tree_attributes: Vec, @@ -72,7 +77,20 @@ impl MotionAnimation { self.motion_attributes.clear(); self.tree_attributes.clear(); self.settable.clear(); - self.skater_animation_flags = Some(0); + //82B98050 preserves local-player and board-present flags, and saved15200. + self.skater_animation_flags = self.skater_animation_flags.map(|f| f & 0x0ff7_ffff); + self.relative_stance = 0; + self.reset_action_intents = true; + self.property = AdvanceResult { crossed_end: false, overshoot: -1.0, remaining_before_wrap: -1.0 }; + } + pub fn reset_to_given_stance(&mut self) -> Result<(), String> { + let flags = self.skater_animation_flags.as_mut().ok_or("Stance reset requires SkaterAnim flags")?; + let mut relative = self.relative_stance != 0; + skate_core::player::offboard::reset_animation::given_stance( + self.natural_stance, &mut self.requested_stance, flags, &mut relative, + ); + self.relative_stance = u32::from(relative); + Ok(()) } pub fn from_metadata(metadata: AnimationMetadata) -> Self { Self { @@ -88,6 +106,10 @@ impl MotionAnimation { posture: PendingPosture::default(), posture_bank_valid: false, skater_animation_flags: None, + natural_stance: 1, + relative_stance: 0, + requested_stance: 0, + reset_action_intents: false, attribute_mirror: std::sync::Arc::new( skate_core::animation::playback_attributes::AttributeMirror(Vec::new()), ), diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index 7baa302a..eec4d9fc 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -110,14 +110,25 @@ impl MotionHost { match operation { crate::graph_host::motion_reset::Operation::SkaterAnimation => { self.action_intents.clear(); + self.action_controls = Default::default(); self.animation.motion_intents.clear(); self.animation.filtered_intents.clear(); + //825953B0's represented selective MG output reset. + self.flags = Default::default(); + self.is_power_sliding = false; + self.riding.last_good_landing_velocity = 0.0; + self.riding.manual_out_timer = 0.0; + self.hand_services.busy_hands = [0; 2]; + self.gesture_publication = None; self.animation.reset_from_stock(); } crate::graph_host::motion_reset::Operation::GivenStance => { - self.animation.posture.set_requested(false); + self.animation.reset_to_given_stance()?; } } + //Later PlayAnimation nodes in this same traversal see the reset. + self.playback_context.is_mirrored = self.animation.skater_animation_flags.map(|f| f & 0x4000_0000 != 0); + self.playback_context.is_switch = Some(self.animation.relative_stance == 1); } return Ok(()); } diff --git a/crates/skate-game/src/graph_host/tests/motion_slide.rs b/crates/skate-game/src/graph_host/tests/motion_slide.rs index 7369cdb8..62cbad56 100644 --- a/crates/skate-game/src/graph_host/tests/motion_slide.rs +++ b/crates/skate-game/src/graph_host/tests/motion_slide.rs @@ -249,3 +249,29 @@ fn slide_direction_preserves_native_unordered_threshold_branch() { 1.0 ); } + +#[test] +#[ignore = "requires installed stock graph assets; compile-only during bail work"] +fn bail_reset_restores_saved_stance_in_the_real_graph_dispatch() { + use crate::graph_host::motion_reset::Operation as Reset; + let mut host = host(); + let reset = behavior(&host, |op| matches!(op, MotionOperation::ResetAnimation(Reset::SkaterAnimation))); + let stance = behavior(&host, |op| matches!(op, MotionOperation::ResetAnimation(Reset::GivenStance))); + for natural in [0, 1] { + for requested in [0, 1] { + host.animation.skater_animation_flags = Some(0xf80a_0000); + host.animation.natural_stance = natural; + host.animation.relative_stance = 1; + host.animation.requested_stance = requested; + host.begin(reset, [0; 6], &frame()); + assert_eq!(host.animation.requested_stance, requested); + assert_eq!(host.animation.skater_animation_flags, Some(0x0802_0000)); + host.begin(stance, [0; 6], &frame()); + assert_eq!(host.animation.requested_stance, 0); + let backwards = natural == requested; + assert_eq!(host.playback_context.is_mirrored, Some(backwards)); + assert_eq!(host.animation.relative_stance, requested); + assert!(host.errors.is_empty(), "{:?}", host.errors); + } + } +} diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 38f053cf..5d44b9e0 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -45,6 +45,7 @@ mod settings; mod wipeout; mod wipeout_states; mod teleport_state; +mod respawn; mod offboard; mod known_air; use crate::{ diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 2c9b97a1..e6073b12 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -76,6 +76,7 @@ pub(super) fn advance( skater.player_input.processed.surface_mode_2540, )?; if teleported { + skater.respawn.reset_measurements(); skater.player_state.reset_for_teleport(); skater.centre_of_mass_filter.reset(); skater.animation_feedback.reset(); @@ -153,7 +154,9 @@ pub(super) fn advance( } skate_core::player::state::PhysicalStateId::Teleporting => { //VT823272FC slot8=82D431F0; slots12/40 are original empty leaves. - skater.teleport_state.update(&skater.player_input.processed); + if skater.teleport_state.update(&skater.player_input.processed) { + super::respawn::request(physics, skater)?; + } } state => return Err(format!("Selected {state:?} requires its physical update")), } @@ -189,6 +192,7 @@ pub(super) fn advance( physical.reckoning.vector_16.map(f32::from_bits), ); let feedback = animation_phase::publish_feedback(physics, skater); + super::respawn::observe(physics, skater)?; let deck = physics.board.bodies()[BodyId::Deck.index()]; let rider = skater .skeleton diff --git a/crates/skate-game/src/physics/grind.rs b/crates/skate-game/src/physics/grind.rs index 98da1490..71cca7ef 100644 --- a/crates/skate-game/src/physics/grind.rs +++ b/crates/skate-game/src/physics/grind.rs @@ -27,6 +27,8 @@ pub(crate) struct Runtime { pub candidate: Option, pub name: String, pub active: bool, + ///82D875A8 fallback result412 bit27: raw contact without accepted family. + pub checkpoint_suppressed: bool, pub kind: u32, pub front_contact: bool, chromosome: chromosome::Chromosome, @@ -71,6 +73,7 @@ impl Runtime { candidate: None, name: String::new(), active: false, + checkpoint_suppressed: false, kind: 0, front_contact: false, chromosome: chromosome::Chromosome::default(), @@ -192,6 +195,7 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { let p = &mut skater.player_input.processed; let runtime = &mut physics.grind; runtime.entry_velocity = None; + runtime.checkpoint_suppressed = false; let board = super::solve::deck_frame(&physics.board); let mut effective_board = board; if p.flags_2468 & 0x0010_0000 != 0 { @@ -258,15 +262,15 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { &runtime.primitives, ); let velocity = p.vectors_400_416[0].map(f32::from_bits); - let slide = grind_contact::deck_contact( + let deck_hit = grind_contact::deck_contact( board, p.flags_2484, runtime.test_depth_epsilon, runtime.test_depth, runtime.deck_to_truck, &edges, - ) - .and_then(|mut hit| { + ); + let slide = deck_hit.and_then(|mut hit| { hit.primitive = nearby[hit.primitive]; grind_contact::boardslide_candidate( board, @@ -314,14 +318,14 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { skater.animation_input.fields.balance, p.vectors_464_480_496_512_528[0].map(f32::from_bits), ); - let dark = families::inverted_contact( + let inverted_hit = families::inverted_contact( board, p.flags_2484, runtime.test_depth_epsilon, runtime.test_depth, &edges, - ) - .and_then(|mut hit| { + ); + let dark = inverted_hit.and_then(|mut hit| { hit.primitive = nearby[hit.primitive]; families::darkslide( board, @@ -355,6 +359,9 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { None }; let selected = current.or(fifty).or(five).or(tip).or(slide).or(dark); + runtime.checkpoint_suppressed = (gated || selected.is_none()) + && (hits.iter().any(Option::is_some) || deck_hit.is_some() + || inverted_hit.is_some() || tip_hits.iter().any(Option::is_some)); let candidate = selected.map(|c| c.geometry); let mut kind = selected.map_or(0, |c| c.kind); runtime.front_contact = selected.is_some_and(|c| c.front); @@ -396,6 +403,7 @@ pub(super) fn query(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { if p.category_2512 != 200 && !grind_contact::within_approach_angle(candidate.direction, velocity, angle) { + runtime.checkpoint_suppressed = true; return; } runtime.candidate = Some(candidate); diff --git a/crates/skate-game/src/physics/offboard/contact_queries.rs b/crates/skate-game/src/physics/offboard/contact_queries.rs index eef6ec26..09c3d090 100644 --- a/crates/skate-game/src/physics/offboard/contact_queries.rs +++ b/crates/skate-game/src/physics/offboard/contact_queries.rs @@ -118,7 +118,7 @@ pub(crate) fn submit( fn xyz(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } -fn query(world: &BoardWorld, probe: Probe, matching_id: u32) -> Result, String> { +pub(crate) fn query(world: &BoardWorld, probe: Probe, matching_id: u32) -> Result, String> { let metadata = world.query_metadata().map_err(str::to_owned)?; let start = xyz(probe.start); let end = xyz(probe.end); diff --git a/crates/skate-game/src/physics/respawn.rs b/crates/skate-game/src/physics/respawn.rs new file mode 100644 index 00000000..02bb3e2f --- /dev/null +++ b/crates/skate-game/src/physics/respawn.rs @@ -0,0 +1,315 @@ +//! TU3 actor checkpoint manager, bound to the single-player static scene. +use super::{GamePhysics, SkaterRuntime, offboard::contact_queries, teleport_state::Checkpoint}; +use skate_core::{ + math::Vector3, + physics::{ + board_world::{BoardWorld, query_metadata::Bounds}, + skeleton_animation_record::AnimationPartTransform as Matrix, + }, + player::{ + offboard::contact_queries::Probe, + respawn::{Candidate, Ground, History, Observation, Validation, surface_allowed}, + }, +}; +use skate_data::collections::Collections; + +pub(super) struct Runtime { + history: History, + settings: Settings, + measurements: i32, +} +struct Settings { + height: f32, + radius: f32, + drop: f32, + normal_y: f32, + minimum_frames: u32, +} +impl Runtime { + pub fn load(data: &Collections, transform: Matrix, stance: u32) -> Result { + let class = "Hash_12B64C0E804B0853"; + let value = |hash| data.float(class, "default", hash); + Ok(Self { + history: History::new(Candidate { + transform, + stance, + offboard: false, + score: 0., + }), + measurements: 0, + settings: Settings { + height: value("Hash_C0526C883AF0ECCA")?, + radius: value("Hash_CEB092E418A5B001")?, + drop: value("Hash_8ABE098D3806D273")?, + normal_y: value("Hash_ADD032CACF6A1C15")?, + minimum_frames: data.integer(class, "default", "Hash_10B7C3A9CC8D3721")? as u32, + }, + }) + } + pub fn reset_measurements(&mut self) { + self.measurements = 0; + } +} + +///Actor82592518 saves stance then queues the ordinary deferred reply825926F8. +pub(super) fn request(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let stance = skater.animation.checkpoint_stance(); + let runtime = &mut skater.respawn; + let candidate = runtime.history.automatic( + stance, + &mut Scene { + world: &physics.world, + settings: &runtime.settings, + }, + )?; + skater.animation.request_checkpoint_stance(candidate.stance); + skater.teleport_state.reply(Checkpoint { + transform: candidate.transform, + on_board: !candidate.offboard, + }); + bevy::log::info!( + "BAIL_CHECKPOINT position={:?} heading={:?} stance={} offboard={} score={}", + candidate.transform[3], + candidate.transform[2], + candidate.stance, + candidate.offboard, + candidate.score + ); + Ok(()) +} + +///One completed physical output, including the empirical conditioner sample. +pub(super) fn observe(physics: &GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let physical = &skater.player_input.physical; + let processed = &skater.player_input.processed; + //82592A00 -> SkateboardReckoning64 ->82C01BF8: solved deck, with bit20 flip. + let mut deck = super::solve::deck_frame(&physics.board); + if processed.flags_2468 & 0x0010_0000 != 0 { + flip(&mut deck); + } + deck[3][1] += 0.2; + let mut offboard = skater.animated_skeleton.roots.animation_to_world; + if processed.flags_2476 & 4 != 0 { + flip(&mut offboard); + } + let velocity = physical.skateboard.vector_80.map(f32::from_bits); + let stance = skater.animation.checkpoint_stance(); + let runtime = &mut skater.respawn; + runtime.measurements = runtime.measurements.wrapping_add(1); + let observation = Observation { + measurements: runtime.measurements, + root_position: skater.animated_skeleton.roots.animation_to_world[3], + com_position: physical.reckoning.vector_64.map(f32::from_bits), + teleport_requested: physical.state.flag_69 != 0, + physical_state: physical.state.state_16, + state_frames: skater.player_state.state_count, + ground_suppressed: physics.grind.checkpoint_suppressed, + offboard_correction: skater.offboard.controller.state.contact.active, + ground_category: super::ground_runtime::active_surface(&physics.riding, &physics.board) + & 0xffff, + //82DB6EC0 publishes the same processed foot record to both fields. + foot_categories: [(processed.left_surface_2596 >> 7) & 31; 2], + riding_transform: heading(deck, velocity), + //82591E30 returns immediately for offboard, before the velocity branch. + offboard_transform: offboard, + stance, + //No alternate-world/challenge controller exists in this local scene. + alternate_world: false, + }; + runtime.history.observe( + &observation, + runtime.settings.minimum_frames, + &mut Scene { + world: &physics.world, + settings: &runtime.settings, + }, + ) +} + +fn flip(matrix: &mut Matrix) { + for i in [0, 2] { + matrix[i] = matrix[i].map(|v| -v); + } +} +///82591E30 retains the source axes at low speed or near vertical travel. +fn heading(mut matrix: Matrix, velocity: [f32; 4]) -> Matrix { + let square = velocity[0] * velocity[0] + velocity[1] * velocity[1] + velocity[2] * velocity[2]; + if square > 0.25 { + let inv = square.sqrt().recip(); + let forward = [velocity[0] * inv, 0., velocity[2] * inv, 0.]; + if forward[0] * forward[0] + forward[2] * forward[2] > 0.9 { + matrix[0] = [forward[2], 0., -forward[0], 0.]; + matrix[1] = [0., 1., 0., 0.]; + matrix[2] = forward; + } + } + matrix +} + +struct Scene<'a> { + world: &'a BoardWorld, + settings: &'a Settings, +} +impl Validation for Scene<'_> { + type Error = String; + fn ground(&mut self, transform: &Matrix) -> Result, String> { + let mut start = transform[3]; + start[1] += 0.1; + let mut end = start; + end[1] -= 10.; + let probe = |start, end, radius| Probe { + start, + end, + radius, + id: 0, + reverse_id: None, + }; + //The only actor has matching identity0; canonical map groups remain active. + let Some(hit) = contact_queries::query(self.world, probe(start, end, 0.), 0)? else { + return Ok(None); + }; + let category = (u32::from(hit.packed_surface) >> 7) & 31; + if hit.geometry.normal.y < self.settings.normal_y + || start[1] - hit.geometry.position.y > self.settings.drop + || !surface_allowed(category) + { + return Ok(None); + } + let mut bottom = start; + bottom[1] += self.settings.radius; + let mut top = bottom; + top[1] += self.settings.height; + if contact_queries::query(self.world, probe(bottom, top, self.settings.radius), 0)? + .is_some() + { + return Ok(None); + } + let p = hit.geometry.position; + Ok(Some(Ground { + position: [p.x, p.y, p.z, 0.], + offboard: category == 8, + })) + } + fn location(&mut self, _: &Matrix) -> Result { + //82BFBB48 returns true in normal-world mode, before provider invocation. + Ok(true) + } + fn occupants(&mut self, _: &Matrix) -> Result { + //82BFB928 queries LivingWorldManager pedestrians/vehicles, not terrain. + //This BoardWorld has no living-world actors. Static obstacles are checked + //by ground's capsule; the player and board must not reject themselves. + self.world.query_metadata().map_err(str::to_owned)?; + Ok(true) + } + fn edges(&mut self, transform: &Matrix) -> Result { + let p = transform[3]; + let bounds = Bounds { + min: Vector3::new(p[0] - 0.3, p[1] - 0.6, p[2] - 0.3), + max: Vector3::new(p[0] + 0.3, p[1] + 0.6, p[2] + 0.3), + }; + let metadata = self.world.query_metadata().map_err(str::to_owned)?; + //82C1EAD8: authored static order, shared capacity40; no triangle diagonals. + for edge in metadata + .static_edges + .iter() + .filter(|e| e.local_bounds.overlaps(bounds)) + .take(40) + { + let start = [edge.start.x, edge.start.y, edge.start.z]; + let end = [edge.end.x, edge.end.y, edge.end.z]; + let delta: [f32; 3] = std::array::from_fn(|i| end[i] - start[i]); + let square: f32 = delta.iter().map(|v| v * v).sum(); + let along: f32 = (0..3).map(|i| (p[i] - start[i]) * delta[i]).sum(); + let fraction = if square > 0. { + (along / square).clamp(0., 1.) + } else { + 0. + }; + let distance: f32 = (0..3) + .map(|i| (p[i] - start[i] - delta[i] * fraction).powi(2)) + .sum(); + if distance < 0.09 { + return Ok(false); + } + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use skate_core::physics::{ + contact::RetailContactMaterial, skeleton_animation_record::IDENTITY, + }; + #[test] + fn real_scene_records_a_checkpoint_and_recovers_it_from_an_unsupported_position() { + let world = super::super::ground::world(RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 0., + }); + let settings = Settings { + height: 0.4, + radius: 0.5, + drop: 1., + normal_y: 0.75, + minimum_frames: 15, + }; + let mut scene = Scene { + world: &world, + settings: &settings, + }; + let mut history = History::new(Candidate { + transform: IDENTITY, + stance: 0, + offboard: false, + score: 0., + }); + //The first conditioning samples clear the construction cooldown. + assert!(!history.recording_due(119, [2., 0., 0., 0.])); + let mut transform = IDENTITY; + transform[3] = [2., 0.2, 0., 0.]; + history + .observe( + &Observation { + measurements: 120, + root_position: transform[3], + com_position: [1000., 0., 0., 0.], + teleport_requested: false, + physical_state: 100, + state_frames: 16, + ground_suppressed: false, + offboard_correction: false, + ground_category: 1, + foot_categories: [1; 2], + riding_transform: transform, + offboard_transform: IDENTITY, + stance: 1, + alternate_world: false, + }, + 15, + &mut scene, + ) + .unwrap(); + let selected = history.automatic(0, &mut scene).unwrap(); + assert_eq!(selected.transform, transform); + assert_eq!(selected.stance, 1); + //Successful historical entries are consumed: a second failure uses spawn. + assert_eq!( + history.automatic(0, &mut scene).unwrap().transform, + IDENTITY + ); + } + #[test] + fn native_heading_keeps_low_speed_and_vertical_axes() { + let mut source = IDENTITY; + source[0] = [0., 0., -1., 0.]; + source[2] = [1., 0., 0., 0.]; + assert_eq!(heading(source, [0., 0., 0.5, 0.]), source); + assert_eq!(heading(source, [0., 5., 0.1, 0.]), source); + let result = heading(source, [0., 0., 2., 0.]); + assert_eq!(result[2], [0., 0., 1., 0.]); + assert_eq!(result[3], source[3]); + } +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 532ac365..77395444 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -47,6 +47,7 @@ pub(crate) struct SkaterRuntime { pub footplant: super::footplant::Footplant, pub wipeout: super::wipeout::Wipeout, pub wipeout_state: super::wipeout_states::WipeoutState, + pub respawn: super::respawn::Runtime, pub teleport_state: super::teleport_state::Runtime, pub offboard: super::offboard::runtime::Runtime, pub skeleton: SkeletonBody, @@ -180,7 +181,9 @@ impl SkaterRuntime { let wipeout_state = super::wipeout_states::WipeoutState::load( &data, asset_root, &animation.evaluator.frames.source_sha256, )?; + let respawn = super::respawn::Runtime::load(&data, spawn, animation.checkpoint_stance())?; Ok(Self { + respawn, climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?, offboard, render_pose: initial_hierarchy, diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index c2079894..95164d29 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -36,20 +36,20 @@ impl Runtime { } /// Update82D431F0 calls the actor only without Processed2468 bit1. ///825926F8 stores the reply and marks it for the next input publication. - pub fn update(&mut self, input: &ProcessedPhysicsInput) { - if self + pub fn update(&mut self, input: &ProcessedPhysicsInput) -> bool { + self .state .update(input.flags_2468, input.matrix_1536, input.byte_1600) == Update::RequestCheckpoint - { - self.request_checkpoint(); - } } /// Host response for an independently verified actor-reset request. pub fn request_checkpoint(&mut self) { + self.reply(self.checkpoint); + } + pub fn reply(&mut self, checkpoint: Checkpoint) { self.pending_reply = Some(Target { - transform: self.checkpoint.transform.map(|row| row.map(f32::to_bits)), - on_board: self.checkpoint.on_board, + transform: checkpoint.transform.map(|row| row.map(f32::to_bits)), + on_board: checkpoint.on_board, }); } /// Consume at the next actor-reset publication boundary. Publish this as diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index e07127d3..4ebd5714 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -62,6 +62,17 @@ pub(crate) struct SkaterAnimation { } impl SkaterAnimation { + ///82592B68: physical leading foot, including the fakie inversion. + pub fn checkpoint_stance(&self) -> u32 { + let p = &self.state.publication; + u32::from(((p.natural_stance == 0 && p.relative_stance == 0) + || (p.natural_stance == 1 && p.relative_stance == 1)) ^ self.state.fakie()) + } + ///82592C08/82B97350: save15200; ResetToGivenStance consumes it later. + pub fn request_checkpoint_stance(&mut self, foot: u32) { + let natural = self.state.publication.natural_stance; + self.motion.animation.requested_stance = u32::from(if foot == 0 { natural != 1 } else { natural == 1 }); + } pub fn stance(&self) -> (bool, bool) { (self.state.fakie(), self.state.mirrored()) } @@ -191,6 +202,10 @@ impl SkaterAnimation { .animation .skater_animation_flags .ok_or("MotionGraph lost the SkaterAnim flag owner")?; + self.state.publication.relative_stance = self.motion.animation.relative_stance as i32; + if std::mem::take(&mut self.motion.animation.reset_action_intents) { + self.action.action_intents.clear(); + } self.motion.animation.apply_parameters()?; if let Some(phase) = self.motion.phase_write.take() { @@ -241,6 +256,8 @@ impl SkaterAnimation { } fn publish_physical(&mut self, p: AnimationPhysical) -> Result<(), String> { + self.motion.animation.natural_stance = self.state.publication.natural_stance as u32; + self.motion.animation.relative_stance = self.state.publication.relative_stance as u32; let speed = p .conditions .speeds diff --git a/docs/native-bail-recovery.md b/docs/native-bail-recovery.md index 08fe00e2..c81d0a51 100644 --- a/docs/native-bail-recovery.md +++ b/docs/native-bail-recovery.md @@ -1,143 +1,135 @@ -# Native bail checkpoint investigation +# Native bail recovery -Status: **selector port only; gameplay integration is incomplete**. The existing -fixed-spawn response in `physics/teleport_state.rs` remains active. This change -must not be described as a working native bail-respawn fix. +Automatic bail recovery now uses the TU3 actor checkpoint manager instead of +always replying with the map's initial spawn. The integration targets the +engine's existing single-player, static-map world. Gameplay validation is +reserved for the user; no game, recomp, controller harness, screenshot capture, +asset-check mode or test executable was launched during this work. -## Reference and scope +## Behavior -Reference: TU3 memory image `default_82000000_011B0000.bin`, mapped at -`0x82000000`, SHA256 -`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. -Addresses below were inspected in the matching generated PPC instruction -comments; constants were checked against the raw image. The read-only IDA -lookup for `82BFB1E0` found no defined function in the locally available dump -database, so it did not provide independent decompiler confirmation. +The actor first validates its current physical COM position using the retained +riding heading. If that fails, it ranks recorded checkpoints and consumes the +selected entry. Entries rejected by validation are also removed, so repeated +bails can walk back through history. Exhaustion uses the initial map transform +and current actor stance. Ordinary recovery preserves history; constructing a +new skater for a map constructs a new history. -No game, recomp, controller harness, screenshot capture or asset-check launch -was performed. No binary/data files belong in this commit. +Recording uses completed physical publications, not render transforms or a +last-grounded shortcut. It requires 120 empirical measurements, more than the +stock 15 frames in an eligible state, expired 20-update cooldown, and at least +1.5 units from every recorded position. The ring retains 32 entries. Surface +categories 5, 6, 9, 12 and 13 are excluded; category 8 requests offboard recovery. +Scores are 100 for categories 1/2, 50 for 3/4, 20 for 11 and zero otherwise. The +newest entry receives a 100-point penalty and entries older than five receive a +200-point penalty; newest wins ties. -## Implemented core +The selected position, heading, stance and offboard byte go through the existing +State702 request/reply publication boundary. Selection does not reset physical +bodies. Existing wipeout timers, recovery countdown, physical reset and ragdoll +restoration remain in place. The explicit fixed-checkpoint helper used by the +existing manual test remains separate from automatic selection. -`skate-core/src/player/respawn.rs` contains the actor history and ordinary -automatic selector, with required scene-validation callbacks. It has no host -default that silently accepts missing geometry or occupant providers. +## Native evidence -- `82BFB068` constructs the manager, seeds the history and starts a 20-update - cooldown. The native 33-slot ring has 32 usable entries. -- `82BFB1E0` publishes current COM position every actor tick. Recording needs - at least 120 empirical measurements, expired cooldown and a root at least - 1.5 units from every recorded position. Falling below 120 clears cooldown. -- `82BFB3F8` rejects State69. Riding states 100/104 publish the current - orientation before testing state age, Ground323 and surface eligibility. - Offboard states 500/502 require the state-age gate and no OffBoard333 - correction. Both paths require strictly more than the configured 15 frames. -- Surface categories 5, 6, 9, 12 and 13 are rejected for riding/ground checks. - Category 8 requests offboard recovery. Scores are 100 for categories 1/2, - 50 for 3/4, 20 for 11, and zero otherwise. -- Recording checks location, edges, then ground unless alternate-world mode - bypasses ground. It stores the original candidate, discarding the ray hit's - position and offboard result. -- `82BFC9B0` tries current position (`82BFC038`), scored history (`82BFC378`), - then fallback (`82BFC718`). Current selection checks ground, location, - occupants and edges, in that order. An offboard ray result replaces only Y. -- `82BFC550` ranks newest to oldest: newest gets a 100-point penalty; entries - older than five get a 200-point penalty. Strict comparisons make newest win - ties. Selection removes the entry before validation, including successful - entries. History validation repeats location/occupant checks only. -- Exhaustion falls back to initial transform, **current actor stance**, on-board - mode and score zero. Manual/session-marker selector `82BFC828` has a different - non-destructive history walk and must remain separate. +Reference memory image `default_82000000_011B0000.bin`, mapped at 0x82000000: +SHA256 `f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. +Instruction comments in the matching generated PPC were inspected; numeric +constants were checked in the raw image. A read-only IDA lookup found no defined +function for 82BFB1E0 in the available dump database, so decompiler output was +not used as independent confirmation. This is a scalar translation, not a +claim of demonstrated bit-exact PPC floating-point equivalence. -The six compile-only unit cases cover consumption/validation order, rejection -and reranking, current-position Y correction, cooldown/distance/capacity -boundaries, surface categories, and exhausted-history stance. They have not -been executed. This is a scalar Rust translation, not demonstrated bit-exact -PPC floating-point equivalence. - -## Required host bindings - -| Native input | Producer / current Rust connection | +| Address | Recovered behavior | | --- | --- | -| State20 | Conditioner164, incremented by `82DE5858`, reset by `82DE5588`; **not** frames in current physical state. No equivalent empirical counter is currently published. | -| State4 / State16 / State69 | Published physical state age, ID and teleport request. | -| Skeleton64 | Physical skeleton COM16144, published by `82BE1AE8`; corresponds to physical reckoning vector64. | -| Skeleton416 | Translation of Skeleton368, the unmirrored animation-to-world11920 matrix; corresponds to `animated_skeleton.roots.animation_to_world`. | -| Ground323 | `82DB6EC0` reads PhysicalPlayer1872 (GrindManager), word460 bit27. Constructor `82D8A318` places its 416-byte result at +48; the relevant result word is +412. The live Rust grind owner has no published equivalent. Its actual set/clear producer still needs recovery. Do not substitute `active`, `grounded`, or constant false. | -| OffBoard333 | Biped708, the offboard contact-correction active flag. | -| OffBoard52/56 | Both are populated from Processed2596 in `82DB6EC0`; category is packed bits7..11. Do not use Processed2600 for the second field. | +| 82BFB068 / 82BFCB38 | History construction and insertion | +| 82BFB1E0 / 82BFB3F8 | Recording gates, current orientation and COM publication | +| 82BFC9B0 | Current -> scored history -> fallback selector | +| 82BFC038 / 82BFC378 / 82BFC550 / 82BFC718 | Current candidate, history validation, scoring/removal, fallback | +| 82BFC828 | Separate non-destructive manual/session-marker query; not used here | +| 82592518 / 825926F8 | Save selected stance, queue deferred actor reset reply | +| 82D431F0 / 82D43280 | State702 reply capture and physical output | +| 82591E30 / 82592A00 / 82C01BF8 | Recording transform and solved deck source | +| 82BFBC18 / 82BFB6F8 | Ground/capsule and authored-edge validation | +| 82DE5858 / 82DE5588 | Empirical measurement count and reset lifecycle | +| 82B97350 / 82B97308 / 82B972A8 | Save15200 and deferred stance restoration | +| 82B98050 / 825953B0 | Selective animation and motion-output reset | -`82591E30` builds the recording transform. Riding reads the actor's board -frame through `82592A00` and adds 0.2 Y. Offboard uses Skeleton432 (mirrored -variant from `82BE3650`). If deck velocity squared exceeds 0.25, it normalizes -the velocity, constructs a world-up basis, and accepts that basis only when -the projected forward length squared exceeds 0.9. The exact host board-frame -binding and floating-point operation ordering still need completion. +## Host bindings -`82BFBC18` traces from candidate position +0.1 Y with delta [0,-10,0], using -query `82E0AAD0` with actor identity. It checks normal Y, ray drop and category, -then tests the vertical capsule above the **ray start**. `82BFB6F8` queries -authored edges in a [0.3,0.6,0.3] half-bound, with the native provider order and -40-edge capacity, and rejects squared segment distance below 0.09. Triangle -diagonals are not a replacement for authored edge data. +Riding checkpoints use the solved board frame, with Processed2468 bit20 +inverting X/Z, plus 0.2 Y. Above speed squared 0.25, a velocity-derived upright +basis is accepted only when projected forward length squared exceeds 0.9. +Offboard checkpoints use the animation-to-world frame with Processed2476 bit2 +inverting X/Z; the native function returns before the velocity-heading branch. -`82BFB928` occupant predicates come from the LivingWorldManager provider -(SimController +212), not simply static collision geometry. `82BFBB48` -conditionally invokes the alternate-world location provider. The host adapter -must explicitly account for the actual available providers and world mode; -these predicates have not been connected or proven equivalent. +Current position is Skeleton64 (physical COM16144); spacing uses Skeleton416, +the unmirrored animation-to-world translation. OffBoard333 comes from the +canonical contact-correction owner. Both native OffBoard52/56 fields receive +Processed2596; their category is packed bits7..11. State age comes from the +physical state publication. The actor-owned empirical count increments once per +completed physical output and resets with physical teleport, retaining history. -## Stock settings found +Ground323 is GrindManager460/result412 bit27. The producer was recovered in +82D875A8: after accepted-family attempts fail, the raw truck/deck/inverted/tip +contact path at 82D87DE8 sets 0x08000000. The host retains those same raw query +results and publishes the rejection flag separately from active grind state. +No new rail distance or last-grounded heuristic is used. -The installed collection data already includes class `Hash_12B64C0E804B0853`, -key `default`. These are read from data, not new tuning defaults: +Ground validation casts from candidate +0.1 Y down ten units, checks normal Y, +maximum drop and category, then tests a vertical capsule above the ray start. +It reuses the canonical triangle query and matching groups. Edge validation +uses authored static edges in provider order, capacity40, with bounds +[0.3,0.6,0.3] and squared segment distance threshold0.09. It does not use triangle +diagonals. Current candidates repeat all predicates; historical candidates +repeat location/occupant checks only, matching native selection. -| Field hash | Value | Use | +Normal-world location validation returns true before invoking the alternate +provider. Occupancy predicates in 82BFB928 belong to LivingWorldManager, not +static terrain. This engine scene has no pedestrian/vehicle actors or alternate +challenge controller, so those providers are empty; static obstacles remain +covered by the capsule. Adding living-world actors or alternate-world maps will +require binding their providers here, as with the existing offboard adapters. + +Saved stance survives ResetSkaterAnimation and is consumed by +ResetToGivenStance at the actual MotionGraph dispatch. Updated flags/relative +stance are visible to later nodes in the same traversal, including TELEPORT +playback. The canonical actor publication then receives the modified values. +The animation reset now preserves the native local-player/board flags and saved +request rather than clearing all flags. The stock `teleport.xml` confirms the +ordered ResetSkaterAnimation -> ResetToGivenStance -> PlayAnimation sequence. + +## Stock data + +Class `Hash_12B64C0E804B0853`, key `default`, already exists in installed data. +No new tuning file or fallback tuning values were introduced. + +| Field hash | Value | Meaning | | --- | --- | --- | | C0526C883AF0ECCA | 0.4 | Capsule height | | CEB092E418A5B001 | 0.5 | Capsule radius | | 8ABE098D3806D273 | 1.0 | Maximum ray drop | | ADD032CACF6A1C15 | 0.75 | Minimum normal Y | -| E64C980EE2114070 | 1.0 | Occupant query radius | -| 59E1F2B3CDC4E658 | 2.0 | Occupant query radius | | 10B7C3A9CC8D3721 | 15 (Int32) | Minimum state age | -Source `skatercollections.vlt` SHA256: +Native occupant-query radii E64C980EE2114070 and 59E1F2B3CDC4E658 are 1 and 2; +the current empty living-world provider does not consume them. +Source skatercollections.vlt SHA256: `3b7dbd062bb1c906a085514355afff35cfa22f486ae70820c5ad1a42a7aab25b`. -## Stance and deferred reset dependency +## Validation and user testing -Actor reset `82592518` chooses the candidate, then calls `82592C08` to save -stance before queuing the reset reply (`825926F8`). Setter `82B97350` writes -animator15200; it does not immediately overwrite relative stance15196. -Getter `82592B68` combines natural15188, relative15196 and the fakie flag. +Release compilation uses the requested x86_64-pc-windows-msvc static CRT flags, +locked dependencies, and no default game features. The six core selector cases +compile with --no-run; they have not been executed. Added host cases cover real +scene recording/recovery, heading thresholds and the actual stock reset dispatch. +The whole game test target cannot currently compile because pre-existing tests +in graphics_menu.rs omit Menu.maps/selected_map, and skate_world.rs omits the +RetailWorldMaterial argument. Those unrelated map/render fixtures were not edited. -MotionGraph `ResetToGivenStance` invokes `82B97308` / `82B972A8`, consuming -saved15200, updating flags15180 and relative15196, then clearing saved15200. -The current game handler instead only clears the posture request. Saved15200 -is absent from the canonical animation owner. The existing pure -`player/offboard/reset_animation.rs` helper and `motion_offboard/reset.rs` -owner contract are not integrated into this dispatch. - -`ResetSkaterAnimation` also needs selective native owner resets: the existing -`MotionAnimation::reset_from_stock` replaces all animation flags with zero, -where `82B98050` applies a selective mask. Connecting saved checkpoint stance -without completing this lifecycle would not preserve native recovery. - -Keep State702's request -> next actor input reply -> State702 output -> physical -reset ordering. Preserve the existing wipeout timers and recovery countdown. -Do not reset bodies from the selector. Map replacement should create a fresh -history; ordinary automatic recovery should retain the consumed history. - -## Validation and remaining work - -The Windows release game build passed with the requested static CRT flags. -After the fallback correction, the core library and six new cases compiled -with `cargo test --release --locked --target x86_64-pc-windows-msvc -p skate-core ---lib --no-run`. No test executable was run. - -Remaining: recover the grind flag producer; complete canonical stance/reset -ownership; bind transforms and empirical counter; implement native scene -predicates; connect observation and actor callback at their publication -boundaries; compile the integrated executable. Gameplay parity remains for -user testing after those dependencies are completed. +The final executable is copied into this task's ignored bin/native-bail-recovery +directory; Test-Native-Bail-Recovery.bat uses that exact copy and installed assets. +It has not been executed. Its sibling gameplay.log records passive BAIL_CHECKPOINT +selection messages. Test riding away from spawn for several seconds, bailing on +open ground and near an edge, repeated bails, both stances, and offboard recovery. +Gameplay parity and visual behavior remain unverified until the user runs it.