Merge native bail recovery with map transition support

This commit is contained in:
chazmm committed 2026-09-08 16:19:52 +01:00
commit 778706b915
15 files changed
+922 -17

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ pub mod offboard;
pub mod conditioner_capabilities;
pub mod teleport_state;
pub mod respawn;
+236
View File
@@ -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<Option<Ground>, Self::Error>;
fn location(&mut self, transform: &Matrix) -> Result<bool, Self::Error>;
fn occupants(&mut self, transform: &Matrix) -> Result<bool, Self::Error>;
fn edges(&mut self, transform: &Matrix) -> Result<bool, Self::Error>;
}
#[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<Candidate>, // 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<V: Validation>(
&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<Candidate> {
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<V: Validation>(
&mut self,
stance: u32,
scene: &mut V,
) -> Result<Candidate, V::Error> {
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;
@@ -0,0 +1,126 @@
use super::*;
struct Scene {
current: bool,
rejected_x: Option<f32>,
calls: Vec<&'static str>,
}
impl Validation for Scene {
type Error = ();
fn ground(&mut self, _: &Matrix) -> Result<Option<Ground>, ()> {
self.calls.push("ground");
Ok(self.current.then_some(Ground {
position: [8., 2., 9., 0.],
offboard: true,
}))
}
fn location(&mut self, m: &Matrix) -> Result<bool, ()> {
self.calls.push("location");
Ok(self.rejected_x != Some(m[3][0]))
}
fn occupants(&mut self, _: &Matrix) -> Result<bool, ()> {
self.calls.push("occupants");
Ok(true)
}
fn edges(&mut self, _: &Matrix) -> Result<bool, ()> {
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::<Vec<_>>(),
[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());
}
@@ -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,
@@ -190,7 +191,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
@@ -77,6 +77,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())
}
@@ -211,6 +222,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() {
@@ -261,6 +276,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
+135
View File
@@ -0,0 +1,135 @@
# Native bail recovery
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.
## Behavior
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.
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.
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.
## Native evidence
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.
| Address | Recovered behavior |
| --- | --- |
| 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 |
## Host bindings
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.
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.
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.
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.
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 |
| 10B7C3A9CC8D3721 | 15 (Int32) | Minimum state age |
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`.
## Validation and user testing
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.
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.