Connect native bail checkpoints and deferred stance recovery

This commit is contained in:
chazmm committed 2026-09-08 16:13:18 +01:00
1 parent 620414a487
commit 43593efee8
12 files changed
+533 -134

No files matched your search

@@ -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<u32>,
/// 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<skate_core::animation::playback_attributes::AttributeMirror>,
settable: SettableAttributes,
tree_attributes: Vec<AnimationAttribute>,
@@ -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()),
),
@@ -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(());
}
@@ -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);
}
}
}
+1
View File
@@ -45,6 +45,7 @@ mod settings;
mod wipeout;
mod wipeout_states;
mod teleport_state;
mod respawn;
mod offboard;
mod known_air;
use crate::{
+5 -1
View File
@@ -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
+14 -6
View File
@@ -27,6 +27,8 @@ pub(crate) struct Runtime {
pub candidate: Option<FiftyFiftyCandidate>,
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);
@@ -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<Option<Hit>, String> {
pub(crate) fn query(world: &BoardWorld, probe: Probe, matching_id: u32) -> Result<Option<Hit>, String> {
let metadata = world.query_metadata().map_err(str::to_owned)?;
let start = xyz(probe.start);
let end = xyz(probe.end);
+315
View File
@@ -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<Self, String> {
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<Option<Ground>, 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<bool, String> {
//82BFBB48 returns true in normal-world mode, before provider invocation.
Ok(true)
}
fn occupants(&mut self, _: &Matrix) -> Result<bool, String> {
//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<bool, String> {
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]);
}
}
+3
View File
@@ -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,
@@ -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
+17
View File
@@ -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
+109 -117
View File
@@ -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.