From b4f5b67d5da0a15ec227f0cd5d293bdbce494a9e Mon Sep 17 00:00:00 2001 From: chazmm Date: Sun, 6 Sep 2026 04:42:07 +0100 Subject: [PATCH] Implement stock bump reaction blend space and graph behavior --- README.md | 5 + crates/skate-core/src/animation/bump.rs | 69 +++ crates/skate-core/src/animation/mod.rs | 1 + .../src/animation/playback_attributes.rs | 31 ++ .../skate-core/src/animation/playback_tree.rs | 12 + .../animation/playback_tree/blend_space.rs | 404 ++++++++++++++++++ crates/skate-core/src/animation/pose_blend.rs | 33 ++ .../src/animation/tests/pose_blend.rs | 13 + crates/skate-data/src/animation_metadata.rs | 21 +- .../src/animation_metadata/blend_space.rs | 143 +++++++ .../src/animation_metadata/native.rs | 2 + crates/skate-game/src/animation_pose.rs | 6 + crates/skate-game/src/graph_host/motion.rs | 13 + .../src/graph_host/motion_animation.rs | 5 + .../motion_animation/selection_space_host.rs | 4 + .../motion_animation/tree_builder.rs | 13 + .../src/graph_host/motion_execute.rs | 12 + .../skate-game/src/graph_host/motion_nodes.rs | 2 + .../src/graph_host/tests/motion_bump.rs | 193 +++++++++ .../src/graph_host/tests/motion_stock.rs | 1 + .../skate-game/src/physics/animation_phase.rs | 1 + docs/BUMP-CRASH-FIX.md | 67 +++ 22 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 crates/skate-core/src/animation/bump.rs create mode 100644 crates/skate-core/src/animation/playback_tree/blend_space.rs create mode 100644 crates/skate-data/src/animation_metadata/blend_space.rs create mode 100644 crates/skate-game/src/graph_host/tests/motion_bump.rs create mode 100644 docs/BUMP-CRASH-FIX.md diff --git a/README.md b/README.md index d5e19534..ca6ac597 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ project do not establish those claims. ## Observed runtime issue +The later bump crash (tick 4159, behaviors 3623/3624) is repaired: the host now +supports the stock `B_BUMP` blend space and `SetBumpCoefficients`. See +[the fix and verification notes](docs/BUMP-CRASH-FIX.md). Visual confirmation +remains with the user; the separate runout issue below is still open. + The build launches and reports `GAME_CHARACTER_READY bones=35`. All 35 skin bones match the supplied stock rig; the decoded frame data contains 487 clips. However, the imported motion-graph host exits when it encounters diff --git a/crates/skate-core/src/animation/bump.rs b/crates/skate-core/src/animation/bump.rs new file mode 100644 index 00000000..d1af8e2a --- /dev/null +++ b/crates/skate-core/src/animation/bump.rs @@ -0,0 +1,69 @@ +//! SetBumpCoefficients Begin82BB0660, ctor82BC7318/vtable823200C8+48. +use crate::physics::native_arithmetic; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub scale_x_acc: f32, + pub min_bump_mag: f32, + pub min_bump_blend_value: f32, + pub max_bump_mag: f32, +} +///Input is PhysOutAnimation112 (already ground-conditioned), not raw world acceleration. +pub fn coefficients(mut acceleration: [f32; 4], mirrored: bool, settings: &Settings) -> [f32; 2] { + acceleration[0] *= settings.scale_x_acc; + let squared = native_arithmetic::dot3(acceleration, acceleration); + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.), inverse); + } + let magnitude = if squared == 0. { 0. } else { squared * inverse }; + let t = ((magnitude - settings.min_bump_mag) / (settings.max_bump_mag - settings.min_bump_mag)) + .clamp(0., 1.); + let weight = t.mul_add( + 1. - settings.min_bump_blend_value, + settings.min_bump_blend_value, + ); + //Initializer82F826F8 broadcasts82181A88 into830BD350: length threshold1e-6. + let direction = if magnitude > f32::from_bits(0x3586_37bd) { + [acceleration[0] * inverse, acceleration[2] * inverse] + } else { + [0.; 2] + }; + direction.map(|v| { + let value = v * weight; + if mirrored { -value } else { value } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bump_direction_scaling_thresholds_and_stance() { + let s = Settings { + scale_x_acc: 2., + min_bump_mag: 10., + min_bump_blend_value: 0.2, + max_bump_mag: 30., + }; + assert_eq!(coefficients([0.; 4], false, &s), [0.; 2]); + for (input, expected) in [ + ([5., 0., 0., 99.], [0.2, 0.]), + ([10., 0., 0., 0.], [0.6, 0.]), + ([0., 0., -40., 0.], [0., -1.]), + ([0.0000001, 0., 0., 0.], [0., 0.]), + ] { + let result = coefficients(input, false, &s); + let mirrored = coefficients(input, true, &s); + for i in 0..2 { + assert!( + (result[i] - expected[i]).abs() < 1e-6, + "{result:?} != {expected:?}" + ); + assert_eq!(mirrored[i], -result[i]); + } + } + let diagonal = coefficients([12., 0., 18., 0.], false, &s); + assert!((diagonal[0] - 0.8).abs() < 1e-6 && (diagonal[1] - 0.6).abs() < 1e-6); + } +} diff --git a/crates/skate-core/src/animation/mod.rs b/crates/skate-core/src/animation/mod.rs index 00960638..d6ec12b0 100644 --- a/crates/skate-core/src/animation/mod.rs +++ b/crates/skate-core/src/animation/mod.rs @@ -1,4 +1,5 @@ //! Animation timing and event semantics recovered from Skate 3 TU3. +pub mod bump; pub mod clip_clock; pub mod commands; pub mod output; diff --git a/crates/skate-core/src/animation/playback_attributes.rs b/crates/skate-core/src/animation/playback_attributes.rs index aefce9ac..4ae06df1 100644 --- a/crates/skate-core/src/animation/playback_attributes.rs +++ b/crates/skate-core/src/animation/playback_attributes.rs @@ -47,6 +47,37 @@ pub fn blend(left: &mut AnimationAttribute, right: &AnimationAttribute, weight: Ok(()) } +///82D16370. Keep reference names and event flags; scale only numeric payload. +pub fn scale(attribute: &mut AnimationAttribute, weight: f32) -> Result<(), String> { + if attribute.status & 2 != 0 { + attribute.begin_time = -1.; attribute.end_time = -1.; + } else { + attribute.begin_time *= weight; attribute.end_time *= weight; + } + for &i in numeric_lanes(attribute.kind) { + let value = f32::from_bits(attribute.payload.0[i].ok_or("Uninitialized weighted attribute")?); + attribute.payload.0[i] = Some((value*weight).to_bits()); + } + Ok(()) +} +///82D16428. Time adds are separate instructions; numeric payload uses FMA. +pub fn add_weighted(left: &mut AnimationAttribute, right: &AnimationAttribute, weight: f32) -> Result<(), String> { + if left.status & 2 == 0 { + left.begin_time += right.begin_time*weight; + left.end_time += right.end_time*weight; + } + for &i in numeric_lanes(left.kind) { + let a = f32::from_bits(left.payload.0[i].ok_or("Uninitialized weighted attribute")?); + let b = f32::from_bits(right.payload.0[i].ok_or("Uninitialized weighted attribute")?); + let value = b.mul_add(weight,a); + left.payload.0[i] = Some(value.to_bits()); + } + Ok(()) +} +fn numeric_lanes(kind:u8) -> &'static [usize] { + match kind {0|2 => &[0],1 => &[0,1,2,3],3 => &[5],_ => &[]} +} + /// Native uses a forward-only intersection, retaining left order/metadata. /// It does not append right-only attributes or replace the left reference name. pub fn intersection(left: Vec, right: &[AnimationAttribute], weight: f32) -> Result, String> { diff --git a/crates/skate-core/src/animation/playback_tree.rs b/crates/skate-core/src/animation/playback_tree.rs index 44f4e1a3..2b7e1692 100644 --- a/crates/skate-core/src/animation/playback_tree.rs +++ b/crates/skate-core/src/animation/playback_tree.rs @@ -1,5 +1,6 @@ //! Owned animation tree evaluation; stock data constructs this topology. pub mod selection_space; +pub mod blend_space; use super::{ clip_clock::AdvanceResult, output::attributes::{AnimationAttribute, AttributeName}, @@ -15,6 +16,7 @@ pub enum PlaybackTree { clip: PlaybackClip, }, PhaseBlend(PhaseBlend), + BlendSpace(blend_space::BlendSpace), SelectionSpace(selection_space::SelectionSpace), Transition(super::playback_transition::PlaybackTransition), ///SkaterAnim::AddBindPose82B98118, latched when a named tree is created. @@ -39,6 +41,7 @@ pub enum PoseCommand { Blend { weight: f32, }, + WeightedBlend { weights: Vec }, ChannelBlend { weight: f32, use_channels_from_weights: bool, @@ -63,6 +66,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.clock.length, Self::PhaseBlend(tree) => tree.length, + Self::BlendSpace(tree) => tree.length(), Self::SelectionSpace(tree) => tree.current().map_or(0.0, Self::length), Self::Transition(tree) => tree.to.length(), Self::BindPose { motion, .. } => motion.length(), @@ -72,6 +76,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.clock.sample_time(), Self::PhaseBlend(tree) => tree.time, + Self::BlendSpace(tree) => tree.time, Self::SelectionSpace(tree) => tree.current().map_or(0.0, Self::time), Self::Transition(tree) => tree.to.time(), Self::BindPose { motion, .. } => motion.time(), @@ -81,6 +86,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.clock.set_time(time), Self::PhaseBlend(tree) => tree.set_time(time), + Self::BlendSpace(tree) => tree.set_time(time), Self::SelectionSpace(tree) => tree.set_time(time), Self::Transition(tree) => tree.to.set_time(time), Self::BindPose { motion, .. } => motion.set_time(time), @@ -90,6 +96,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.clock.set_speed(speed), Self::PhaseBlend(tree) => tree.set_speed(speed), + Self::BlendSpace(tree) => tree.set_speed(speed), Self::SelectionSpace(tree) => tree.set_speed(speed), Self::Transition(tree) => tree.to.set_speed(speed), Self::BindPose { motion, .. } => motion.set_speed(speed), @@ -99,6 +106,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.clock.advance(dt, phase, property), Self::PhaseBlend(tree) => tree.advance(dt, phase, property), + Self::BlendSpace(tree) => tree.advance(dt, phase, property), Self::SelectionSpace(tree) => { if let Some(child) = tree.current_mut() { child.advance(dt, phase, property); @@ -112,6 +120,7 @@ impl PlaybackTree { match self { Self::Clip { .. } => Ok(false), Self::PhaseBlend(tree) => tree.set_attributes(attributes), + Self::BlendSpace(tree) => tree.set_attributes(attributes), Self::SelectionSpace(tree) => tree.set_attributes(attributes), Self::Transition(tree) => tree.set_attributes(attributes), Self::BindPose { motion, .. } => motion.set_attributes(attributes), @@ -121,6 +130,7 @@ impl PlaybackTree { match self { Self::Clip { clip, .. } => clip.attributes(mask), Self::PhaseBlend(tree) => tree.attributes(mask), + Self::BlendSpace(tree) => tree.attributes(mask), Self::SelectionSpace(tree) => tree .current() .ok_or("SelectionSpace attributes requested before native selection")? @@ -161,6 +171,7 @@ impl PlaybackTree { ) -> Result { match self { Self::PhaseBlend(tree) => tree.query_attribute(name, mask, output), + Self::BlendSpace(tree) => tree.query_attribute(name, mask, output), Self::SelectionSpace(tree) => tree .current() .ok_or("SelectionSpace attribute requested before native selection")? @@ -198,6 +209,7 @@ impl PlaybackTree { ) -> Result { match self { Self::PhaseBlend(tree) => tree.evaluate(parameters, enabled, output), + Self::BlendSpace(tree) => tree.evaluate(parameters, enabled, output), Self::SelectionSpace(tree) => { if !enabled { Ok(false) diff --git a/crates/skate-core/src/animation/playback_tree/blend_space.rs b/crates/skate-core/src/animation/playback_tree/blend_space.rs new file mode 100644 index 00000000..66897c34 --- /dev/null +++ b/crates/skate-core/src/animation/playback_tree/blend_space.rs @@ -0,0 +1,404 @@ +//! Andale BlendSpace82D22DA0..82D24B90. Authored simplex planes, original +//! scalar selection82D23A50 and phase-coupled child clocks; no fitted metric. +use super::{ + AdvanceResult, AnimationAttribute, AttributeName, Evaluation, PlaybackTree, PoseCommand, + SettableAttribute, +}; +use crate::animation::{output::attributes::MotionGraphAttribute, playback_attributes}; + +#[derive(Clone, Debug)] +pub struct Simplex { + pub children: Vec, + pub vertices: Vec>, + pub normals: Vec>, + pub scales: Vec, +} +impl Simplex { + ///82D23898: signed distances to the opposite planes, scaled by inverse height. + pub fn coordinates(&self, point: &[f32]) -> Vec { + (0..self.children.len()) + .map(|i| { + let anchor = &self.vertices[(i + 1) % self.children.len()]; + let mut even = 0.0; + let mut odd = 0.0; + let pairs = point.len() / 2; + for j in 0..pairs { + even = self.normals[i][j * 2].mul_add(point[j * 2] - anchor[j * 2], even); + odd = self.normals[i][j * 2 + 1] + .mul_add(point[j * 2 + 1] - anchor[j * 2 + 1], odd); + } + let mut dot = even + odd; + if point.len() % 2 != 0 { + let j = point.len() - 1; + dot += self.normals[i][j] * (point[j] - anchor[j]); + } + dot * self.scales[i] + }) + .collect() + } + ///82D23530/82D23080: successive coordinate-plane slices, retaining edge + ///enumeration order. On an empty slice choose its nearest remaining vertex. + fn project(&self, point: &[f32]) -> Vec { + let mut matrix = self.vertices.clone(); + let mut projected = Vec::with_capacity(point.len()); + for &target in point { + let count = matrix.len() - 1; + let mut sliced = Vec::new(); + 'edges: for i in 0..count { + for j in i + 1..matrix.len() { + if sliced.len() == count { + break 'edges; + } + let a = &matrix[i]; + let b = &matrix[j]; + if (a[0] < target) == (b[0] < target) { + continue; + } + let delta = b[0] - a[0]; + if delta.abs() <= f32::from_bits(0x3727_c5ac) { + //8219B100 + sliced.push(a[1..].to_vec()); + if sliced.len() < count { + sliced.push(b[1..].to_vec()); + } + } else { + let t = (target - a[0]) / delta; + sliced.push( + a[1..] + .iter() + .zip(&b[1..]) + .map(|(&a, &b)| (b - a).mul_add(t, a)) + .collect(), + ); + } + } + } + if sliced.is_empty() { + let mut nearest = 0; + let mut distance = f32::MAX; + for (i, row) in matrix.iter().enumerate() { + let d = (row[0] - target) * (row[0] - target); + if d < distance { + distance = d; + nearest = i; + } + } + projected.extend_from_slice(&matrix[nearest]); + break; + } + projected.push(target); + while sliced.len() < count { + sliced.push(sliced.last().unwrap().clone()); + } + matrix = sliced; + } + projected + } +} + +#[derive(Clone, Debug)] +pub struct BlendSpace { + pub parameters: Vec, + pub children: Vec, + pub simplexes: Vec, + values: Vec, + current: usize, + weights: Vec, + pub time: f32, +} +impl BlendSpace { + pub fn new( + parameters: Vec, + children: Vec, + simplexes: Vec, + ) -> Result { + let d = parameters.len(); + if d == 0 + || d > 4 + || children.len() < d + 1 + || simplexes.is_empty() + || simplexes.iter().any(|s| { + s.children.len() != d + 1 + || s.vertices.len() != d + 1 + || s.normals.len() != d + 1 + || s.scales.len() != d + 1 + || s.children.iter().any(|&i| i >= children.len()) + || s.vertices + .iter() + .chain(&s.normals) + .any(|v| v.len() != d || v.iter().any(|v| !v.is_finite())) + || s.scales.iter().any(|v| !v.is_finite()) + }) + { + return Err("Invalid authored BlendSpace topology".into()); + } + let mut weights = vec![0.; d + 1]; + weights[0] = 1.; + Ok(Self { + parameters, + children, + simplexes, + values: vec![0.; d], + current: 0, + weights, + time: 0., + }) + } + pub fn active_weights(&self) -> impl Iterator + '_ { + self.simplexes[self.current] + .children + .iter() + .copied() + .zip(self.weights.iter().copied()) + } + pub fn length(&self) -> f32 { + self.active_weights() + .fold(0., |sum, (i, w)| w.mul_add(self.children[i].length(), sum)) + } + pub fn set_time(&mut self, time: f32) { + let phase = time / self.length(); + for &i in &self.simplexes[self.current].children { + let child = &mut self.children[i]; + child.set_time(phase * child.length()); + } + //82D24AB0 updates child clocks; the cached time is written by Advance. + } + pub fn set_speed(&mut self, speed: f32) { + for child in &mut self.children { + child.set_speed(speed); + } + } + pub fn advance(&mut self, dt: f32, phase: f32, property: &mut AdvanceResult) { + let length = self.length(); + let indices = &self.simplexes[self.current].children; + let first = indices[0]; + let first_length = self.children[first].length(); + for (slot, &i) in indices.iter().enumerate() { + let child = &mut self.children[i]; + let mut result = AdvanceResult { + crossed_end: false, + overshoot: -1., + remaining_before_wrap: -1., + }; + child.advance((child.length() * (1. / length)) * dt, phase, &mut result); + if slot == 0 { + *property = result; + } + } + let scale = length / first_length; + property.overshoot *= scale; + property.remaining_before_wrap *= scale; + self.time = self.children[first].time() * scale; + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result { + let mut changed = false; + for attribute in attributes { + if let Some(i) = self.parameters.iter().position(|&n| n == attribute.name) { + if !attribute.value.is_finite() { + return Err("Nonfinite BlendSpace parameter".into()); + } + self.values[i] = attribute.value; + changed = true; + } + } + if !changed { + return Ok(false); + } + let mut selected = None; + let mut closest = f32::MAX; + for (index, simplex) in self.simplexes.iter().enumerate() { + let mut weights = simplex.coordinates(&self.values); + if weights.iter().all(|&w| w >= 0.) { + selected = Some((index, weights)); + break; + } + weights = simplex.coordinates(&simplex.project(&self.values)); + for w in &mut weights { + if *w < 0. { + *w = 0.; + } + } + normalize(&mut weights)?; + let mut distance = 0.; + for (j, &target) in self.values.iter().enumerate() { + let projected = simplex + .vertices + .iter() + .zip(&weights) + .fold(0., |sum, (v, &w)| v[j].mul_add(w, sum)); + distance = (projected - target).mul_add(projected - target, distance); + } + if distance < closest { + closest = distance; + selected = Some((index, weights)); + } + } + let (index, mut weights) = selected.ok_or("BlendSpace has no finite projection")?; + for w in &mut weights { + *w = w.clamp(0., 1.); + } + normalize(&mut weights)?; + if index != self.current { + let old = &self.children[self.simplexes[self.current].children[0]]; + let phase = old.time() / old.length(); + for &i in &self.simplexes[index].children { + let child = &mut self.children[i]; + child.set_time(phase * child.length()); + } + self.current = index; + } + self.weights = weights; + Ok(true) + } + pub fn attributes(&self, mask: u32) -> Result, String> { + let indices = &self.simplexes[self.current].children; + let mut output = self.children[indices[0]].attributes(mask)?; + for a in &mut output { + playback_attributes::scale(a, self.weights[0])?; + } + for (slot, &i) in indices.iter().enumerate().skip(1) { + let right = self.children[i].attributes(mask)?; + let mut next = 0; + let mut combined = Vec::new(); + for mut a in output { + while next < right.len() && right[next].name.0 < a.name.0 { + next += 1; + } + if let Some(b) = right.get(next).filter(|b| b.name == a.name) { + playback_attributes::add_weighted(&mut a, b, self.weights[slot])?; + combined.push(a); + } + } + output = combined; + } + Ok(output) + } + pub fn query_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result { + //82D24580 indexes the first d+1 authored children directly (unlike + //GetAttributes, which follows the active simplex). Preserve that distinction. + if !self.children[0].query_attribute(name, mask, output)? { + return Ok(false); + } + playback_attributes::scale(output, self.weights[0])?; + for i in 1..self.weights.len() { + let mut right = MotionGraphAttribute { name, value: 0. }.to_animation(); + if !self.children[i].query_attribute(name, 15, &mut right)? { + return Ok(false); + } + playback_attributes::add_weighted(output, &right, self.weights[i])?; + } + Ok(true) + } + pub fn evaluate( + &mut self, + parameters: Evaluation, + enabled: bool, + output: &mut Vec, + ) -> Result { + if !enabled { + return Ok(false); + } + for &i in &self.simplexes[self.current].children { + if !self.children[i].evaluate(parameters, true, output)? { + return Err("BlendSpace child produced no pose".into()); + } + } + output.push(PoseCommand::WeightedBlend { + weights: self.weights.clone(), + }); + Ok(true) + } +} +fn normalize(weights: &mut [f32]) -> Result<(), String> { + let sum: f32 = weights.iter().sum(); + if !sum.is_finite() || sum <= 0. { + return Err("Degenerate BlendSpace weights".into()); + } + let inverse = 1. / sum; + for w in weights { + *w *= inverse; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::animation::{playback_clip::PlaybackClip, skeleton_input::name::encode}; + fn triangle() -> Simplex { + Simplex { + children: vec![0, 1, 2], + vertices: vec![vec![0., 0.], vec![1., 0.], vec![0., 1.]], + normals: vec![vec![-1., -1.], vec![1., 0.], vec![0., 1.]], + scales: vec![1.; 3], + } + } + #[test] + fn bump_space_planes_and_original_outside_projection() { + let s = triangle(); + assert_eq!(s.coordinates(&[0.25, 0.5]), vec![0.25, 0.25, 0.5]); + assert_eq!(s.project(&[2., 0.5]), vec![1., 0.]); + assert_eq!(s.project(&[0.5, 2.]), vec![0.5, 0.5]); + assert_eq!(s.project(&[-1., -1.]), vec![0., 0.]); + } + #[test] + fn bump_space_clocks_share_phase_and_keep_all_weighted_poses() { + let children = (1..=3) + .map(|i| PlaybackTree::Clip { + name: format!("CLIP{i}"), + clip: PlaybackClip::new((i * 30 + 1) as f32, 30., 1., 0, Vec::new()), + }) + .collect(); + let x = encode(b"X"); + let y = encode(b"Y"); + let mut tree = BlendSpace::new(vec![x, y], children, vec![triangle()]).unwrap(); + tree.set_attributes(&[ + SettableAttribute { + name: x, + value: 0.25, + normalized: false, + sequence_id: -1, + }, + SettableAttribute { + name: y, + value: 0.5, + normalized: false, + sequence_id: -1, + }, + ]) + .unwrap(); + let length = tree.length(); + tree.set_time(length * 0.2); + let mut property = AdvanceResult { + crossed_end: false, + overshoot: -1., + remaining_before_wrap: -1., + }; + tree.advance(length * 0.1, 0., &mut property); + for child in &tree.children { + assert!((child.time() / child.length() - 0.3).abs() < 1e-6); + } + let mut commands = Vec::new(); + tree.evaluate( + Evaluation { + cull_threshold: 0.01, + update_history: true, + }, + true, + &mut commands, + ) + .unwrap(); + assert_eq!(commands.len(), 4); + assert_eq!( + commands.last(), + Some(&PoseCommand::WeightedBlend { + weights: vec![0.25, 0.25, 0.5] + }) + ); + } +} diff --git a/crates/skate-core/src/animation/pose_blend.rs b/crates/skate-core/src/animation/pose_blend.rs index bf178f29..5d00abfc 100644 --- a/crates/skate-core/src/animation/pose_blend.rs +++ b/crates/skate-core/src/animation/pose_blend.rs @@ -57,6 +57,39 @@ fn interpolate(first: [f32; 4], second: [f32; 4], weight: f32) -> [f32; 4] { core::array::from_fn(|lane| (second[lane] - first[lane]).mul_add(weight, first[lane])) } +///ACS828CBF68..828CC0B4: weighted SQT accumulation in authored order, +///then one quaternion normalization. This is distinct from repeated two-pose +///shortest-arc blending; the native multi-pose path does not flip hemispheres. +pub fn weighted(poses: &[Vec], weights: &[f32]) -> Result, PoseBufferError> { + let Some(first) = poses.first() else {return Err(PoseBufferError::ShortInput);}; + if poses.len()!=weights.len() || poses.iter().any(|p| p.len()!=first.len()) { + return Err(PoseBufferError::ShortInput); + } + let mut output=first.iter().map(|s| Sqt { + scale:s.scale.map(|v| v*weights[0]), + rotation:s.rotation.map(|v| v*weights[0]), + translation:s.translation.map(|v| v*weights[0]), + }).collect::>(); + for (pose,&weight) in poses.iter().zip(weights).skip(1) { + for (out,s) in output.iter_mut().zip(pose) { + for lane in 0..4 { + out.scale[lane]=s.scale[lane].mul_add(weight,out.scale[lane]); + out.rotation[lane]=s.rotation[lane].mul_add(weight,out.rotation[lane]); + out.translation[lane]=s.translation[lane].mul_add(weight,out.translation[lane]); + } + } + } + for out in &mut output { + let squared=native_arithmetic::dot4(out.rotation,out.rotation); + let mut inverse=native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse=(inverse*0.5).mul_add((-squared).mul_add(inverse*inverse,1.),inverse); + } + out.rotation=out.rotation.map(|v| v*inverse); + } + Ok(output) +} + #[cfg(test)] #[path = "tests/pose_blend.rs"] mod tests; diff --git a/crates/skate-core/src/animation/tests/pose_blend.rs b/crates/skate-core/src/animation/tests/pose_blend.rs index ec820dfb..28cd510f 100644 --- a/crates/skate-core/src/animation/tests/pose_blend.rs +++ b/crates/skate-core/src/animation/tests/pose_blend.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn bump_multi_blend_accumulates_before_normalizing_without_hemisphere_flip() { + let a=pose([0.,0.,0.,1.]); + let mut b=pose([0.6,0.,0.,-0.8]); + b.translation=[4.,8.,12.,0.]; + let c=pose([0.,0.6,0.,0.8]); + let output=weighted(&[vec![a],vec![b],vec![c]],&[0.5,0.25,0.25]).unwrap()[0]; + assert_eq!(output.translation,[1.,2.,3.,0.]); + let raw=[0.15_f32,0.15,0.,0.5]; + let norm=raw.iter().map(|v| v*v).sum::().sqrt(); + for i in 0..4 {assert!((output.rotation[i]-raw[i]/norm).abs()<1e-6);} +} + fn pose(rotation: [f32; 4]) -> Sqt { Sqt { scale: [1.0; 4], diff --git a/crates/skate-data/src/animation_metadata.rs b/crates/skate-data/src/animation_metadata.rs index 11aa78ba..978d5243 100644 --- a/crates/skate-data/src/animation_metadata.rs +++ b/crates/skate-data/src/animation_metadata.rs @@ -4,6 +4,8 @@ use serde::Deserialize; use std::{collections::BTreeMap, fs, path::Path}; mod native; +mod blend_space; +pub use blend_space::{BlendSpaceMetadata, BlendSimplexMetadata}; mod selection_space; pub use selection_space::{ SelectionCandidateMetadata, SelectionParameterMetadata, SelectionSpaceMetadata, @@ -52,6 +54,7 @@ pub struct PhaseBlendMetadata { } pub enum TreeMetadata<'a> { Clip(&'a ClipMetadata), + BlendSpace(&'a BlendSpaceMetadata), PhaseBlend(&'a PhaseBlendMetadata), Selector(&'a SelectorMetadata), SelectionSpace(&'a SelectionSpaceMetadata), @@ -86,6 +89,8 @@ struct File { #[serde(default)] phase_blends: Vec, #[serde(default)] + blend_spaces: Vec, + #[serde(default)] selectors: Vec, #[serde(default)] selection_spaces: Vec, @@ -98,6 +103,7 @@ pub struct AnimationMetadata { pub source_sha256: String, clips: BTreeMap>, phase_blends: BTreeMap>, + blend_spaces: BTreeMap>, selectors: BTreeMap>, selection_spaces: BTreeMap>, unsupported: BTreeMap>, @@ -191,6 +197,11 @@ impl AnimationMetadata { .or_default() .push(tree); } + let mut blend_spaces: BTreeMap> = BTreeMap::new(); + for tree in file.blend_spaces { + tree.validate(file.source_bytes)?; + blend_spaces.entry(tree.name.clone()).or_default().push(tree); + } let mut selectors: BTreeMap> = BTreeMap::new(); for tree in file.selectors { validate_name(&tree.name, 36)?; @@ -231,7 +242,7 @@ impl AnimationMetadata { } let origins = clips .keys() - .chain(phase_blends.keys()) + .chain(phase_blends.keys()).chain(blend_spaces.keys()) .chain(selectors.keys()) .chain(selection_spaces.keys()) .chain(unsupported.keys()) @@ -247,6 +258,7 @@ impl AnimationMetadata { source_sha256: file.source_sha256, clips, phase_blends, + blend_spaces, selectors, selection_spaces, unsupported, @@ -276,6 +288,7 @@ impl AnimationMetadata { self.sources.extend(other.sources); self.clips.extend(other.clips); self.phase_blends.extend(other.phase_blends); + self.blend_spaces.extend(other.blend_spaces); self.selectors.extend(other.selectors); self.selection_spaces.extend(other.selection_spaces); self.unsupported.extend(other.unsupported); @@ -319,6 +332,7 @@ impl AnimationMetadata { .get(&name) .map(Vec::as_slice) .unwrap_or(&[]); + let blend_spaces = self.blend_spaces.get(&name).map(Vec::as_slice).unwrap_or(&[]); let selectors = self.selectors.get(&name).map(Vec::as_slice).unwrap_or(&[]); let selection_spaces = self .selection_spaces @@ -330,7 +344,7 @@ impl AnimationMetadata { let last = clips .iter() .map(|c| c.source_offset) - .chain(phase_blends.iter().map(|t| t.source_offset)) + .chain(phase_blends.iter().map(|t| t.source_offset)).chain(blend_spaces.iter().map(|t| t.source_offset)) .chain(selectors.iter().map(|t| t.source_offset)) .chain(selection_spaces.iter().map(|t| t.source_offset)) .chain(trees.iter().map(|t| t.0)) @@ -341,6 +355,9 @@ impl AnimationMetadata { if let Some(tree) = phase_blends.iter().find(|t| Some(t.source_offset) == last) { return Ok(TreeMetadata::PhaseBlend(tree)); } + if let Some(tree) = blend_spaces.iter().find(|t| Some(t.source_offset) == last) { + return Ok(TreeMetadata::BlendSpace(tree)); + } if let Some(tree) = selectors.iter().find(|t| Some(t.source_offset) == last) { return Ok(TreeMetadata::Selector(tree)); } diff --git a/crates/skate-data/src/animation_metadata/blend_space.rs b/crates/skate-data/src/animation_metadata/blend_space.rs new file mode 100644 index 00000000..48d52715 --- /dev/null +++ b/crates/skate-data/src/animation_metadata/blend_space.rs @@ -0,0 +1,143 @@ +//! Andale type6: construction82D1B8D0, layout82D22DA0, simplex82D23898. +use super::validate_name; +use crate::abin::{Error, Reader, RecordHeader, Result}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BlendSimplexMetadata { + pub children: Vec, + /// Spatial coordinates only; the serialized matrix also has a final column. + pub vertex_bits: Vec>, + pub normal_bits: Vec>, + pub scale_bits: Vec, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BlendSpaceMetadata { + pub name: String, + pub source_offset: u64, + pub parameters: Vec, + pub children: Vec, + pub simplexes: Vec, +} +impl BlendSpaceMetadata { + pub(super) fn validate(&self, bytes: u64) -> std::result::Result<(), String> { + validate_name(&self.name, 36)?; + let d = self.parameters.len(); + if self.source_offset >= bytes + || d == 0 + || d > 4 + || self.children.len() < d + 1 + || self.simplexes.is_empty() + { + return Err(format!( + "{}: invalid BlendSpace dimensions/offset", + self.name + )); + } + for name in &self.parameters { + validate_name(name, 30)?; + } + for name in &self.children { + validate_name(name, 36)?; + } + for s in &self.simplexes { + if s.children.len() != d + 1 + || s.scale_bits.len() != d + 1 + || s.vertex_bits.len() != d + 1 + || s.normal_bits.len() != d + 1 + || s.children.iter().any(|&i| i >= self.children.len()) + || s.vertex_bits + .iter() + .chain(&s.normal_bits) + .any(|v| v.len() != d) + || s.vertex_bits + .iter() + .chain(&s.normal_bits) + .flatten() + .chain(&s.scale_bits) + .any(|&v| !f32::from_bits(v).is_finite()) + { + return Err(format!("{}: invalid BlendSpace simplex", self.name)); + } + } + Ok(()) + } +} +pub(super) fn read(r: Reader<'_>, h: &RecordHeader) -> Result { + let p = h.payload_offset; + let count = r.u32(p)? as usize; + let names_size = count + .checked_mul(24) + .ok_or_else(|| Error::new(p, "BlendSpace count overflow"))?; + let names = r.relative(p, r.u32(p + 4)?, names_size)?.start; + let q = r.relative(p, r.u32(p + 12)?, 44)?.start; + let space = r.bounded(r.relative(q, 0, r.u32(q)? as usize)?)?; + let d = space.u32(q + 28)? as usize; + if d == 0 || d > 4 || d > r.u32(p + 16)? as usize { + return Err(Error::new(q + 28, "invalid BlendSpace dimension")); + } + let params = space.relative(q, space.u32(q + 32)?, d * 20)?.start; + let simplex_count = space.u32(q + 36)? as usize; + // Every record must at least contain its header; bound the count before allocating. + space.relative( + q, + space.u32(q + 40)?, + simplex_count + .checked_mul(20) + .ok_or_else(|| Error::new(q, "BlendSpace simplex count overflow"))?, + )?; + let mut at = space.relative(q, space.u32(q + 40)?, 20)?.start; + let mut simplexes = Vec::new(); + for _ in 0..simplex_count { + let size = space.u32(at)? as usize; + let s = space.bounded(space.relative(at, 0, size)?)?; + if s.u32(at + 16)? as usize != d + 1 { + return Err(Error::new(at + 16, "BlendSpace matrix dimension mismatch")); + } + s.take(at + 20, (d + 1) * (d + 1) * 4)?; + let indices = s.relative(at, s.u32(at + 4)?, (d + 1) * 4)?.start; + let scales = s.relative(at, s.u32(at + 8)?, (d + 1) * 4)?.start; + let normals = s + .relative(at, s.u32(at + 12)?, (d + 1) * (d + 1) * 4)? + .start; + let mut simplex = BlendSimplexMetadata { + children: Vec::new(), + vertex_bits: Vec::new(), + normal_bits: Vec::new(), + scale_bits: Vec::new(), + }; + for i in 0..=d { + let normal = normals + i * (d + 1) * 4; + if s.u32(normal)? as usize != d { + return Err(Error::new(normal, "BlendSpace normal dimension mismatch")); + } + simplex.children.push(s.u32(indices + i * 4)? as usize); + simplex.scale_bits.push(s.u32(scales + i * 4)?); + simplex.vertex_bits.push( + (0..d) + .map(|j| s.u32(at + 20 + (i * (d + 1) + j) * 4)) + .collect::>()?, + ); + simplex.normal_bits.push( + (0..d) + .map(|j| s.u32(normal + 4 + j * 4)) + .collect::>()?, + ); + } + simplexes.push(simplex); + at += size; + } + Ok(BlendSpaceMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameters: (0..d) + .map(|i| space.name(params + i * 20, 5)) + .collect::>()?, + children: (0..count) + .map(|i| r.name(names + i * 24, 6)) + .collect::>()?, + simplexes, + }) +} diff --git a/crates/skate-data/src/animation_metadata/native.rs b/crates/skate-data/src/animation_metadata/native.rs index cc914528..99b17e2d 100644 --- a/crates/skate-data/src/animation_metadata/native.rs +++ b/crates/skate-data/src/animation_metadata/native.rs @@ -21,6 +21,7 @@ impl AnimationMetadata { source_bytes: bank.bytes().len() as u64, clips: Vec::new(), phase_blends: Vec::new(), + blend_spaces: Vec::new(), selectors: Vec::new(), selection_spaces: Vec::new(), unsupported_trees: Vec::new(), @@ -35,6 +36,7 @@ impl AnimationMetadata { .push(read_clip(r, h, clip).map_err(|e| e.to_string())?), RecordData::Pose(_) | RecordData::Hierarchy(_) | RecordData::PhysicsPose(_) => {} RecordData::Opaque => match h.type_id { + 6 => file.blend_spaces.push(super::blend_space::read(r, h).map_err(|e| e.to_string())?), 7 => file .phase_blends .push(read_phase_blend(r, h).map_err(|e| e.to_string())?), diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs index 5faa0a84..55a46dc0 100644 --- a/crates/skate-game/src/animation_pose.rs +++ b/crates/skate-game/src/animation_pose.rs @@ -95,6 +95,12 @@ impl PoseEvaluator { } stack.push(pose); } + PoseCommand::WeightedBlend { weights } => { + let start=stack.len().checked_sub(weights.len()).ok_or("Weighted blend pose stack underflow")?; + let pose=skate_core::animation::pose_blend::weighted(&stack[start..],weights) + .map_err(|e| format!("Weighted blend: {e:?}"))?; + stack.truncate(start); stack.push(pose); + } PoseCommand::Blend { weight } | PoseCommand::ChannelBlend { weight, .. } => { let second = stack.pop().ok_or("Animation blend has no second subtree")?; let first = stack diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs index 61c877fd..ef7aa3d4 100644 --- a/crates/skate-game/src/graph_host/motion.rs +++ b/crates/skate-game/src/graph_host/motion.rs @@ -151,6 +151,8 @@ pub struct MotionHost { wipeout_settings: super::motion_wipeout::Settings, ///Native PhysOutGround pumping acceleration268. pub pumping_acceleration: Option, + pub bump_acceleration: Option<[f32;4]>, + bump_settings: skate_core::animation::bump::Settings, pub allow_pumping: bool, pub riding: super::motion_riding::RidingState, pub errors: Vec, @@ -236,6 +238,13 @@ impl MotionHost { wipeout_controls: Default::default(), wipeout_settings: super::motion_wipeout::Settings::load(data)?, pumping_acceleration: None, + bump_acceleration: None, + bump_settings: skate_core::animation::bump::Settings { + scale_x_acc:data.float("anim_motion","bumps","scale_x_acc")?, + min_bump_mag:data.float("anim_motion","bumps","min_bump_mag")?, + min_bump_blend_value:data.float("anim_motion","bumps","min_bump_blend_value")?, + max_bump_mag:data.float("anim_motion","bumps","max_bump_mag")?, + }, //8258F488 seeds bit24, and reset825953B0 preserves that bit. allow_pumping: true, riding: super::motion_riding::RidingState::new(), @@ -359,3 +368,7 @@ mod tests; #[cfg(test)] #[path = "tests/motion_slide.rs"] mod slide_tests; + +#[cfg(test)] +#[path = "tests/motion_bump.rs"] +mod bump_tests; diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs index 9a9a084e..9802fa8a 100644 --- a/crates/skate-game/src/graph_host/motion_animation.rs +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -286,6 +286,7 @@ fn has_transition(tree: &PlaybackTree) -> bool { match tree { PlaybackTree::Transition(_) => true, PlaybackTree::PhaseBlend(tree) => tree.children.iter().any(has_transition), + PlaybackTree::BlendSpace(tree) => tree.children.iter().any(has_transition), PlaybackTree::SelectionSpace(tree) => tree.current().is_some_and(has_transition), PlaybackTree::BindPose { motion, .. } => has_transition(motion), _ => false, @@ -306,6 +307,10 @@ fn prune(tree: PlaybackTree) -> PlaybackTree { tree.children = tree.children.into_iter().map(prune).collect(); PlaybackTree::PhaseBlend(tree) } + PlaybackTree::BlendSpace(mut tree) => { + tree.children = tree.children.into_iter().map(prune).collect(); + PlaybackTree::BlendSpace(tree) + } PlaybackTree::SelectionSpace(mut tree) => { tree.candidates = tree .candidates diff --git a/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs index 377a7e96..585a39af 100644 --- a/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs +++ b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs @@ -28,6 +28,10 @@ impl MotionAnimation { } self.prepare_selection_spaces(space.current_mut().unwrap(), attributes)?; } + PlaybackTree::BlendSpace(space) => { + // Type6 SetAttributes82D24078 consumes its own parameters only. + let _ = space; + } PlaybackTree::PhaseBlend(space) => { for child in &mut space.children { self.prepare_selection_spaces(child, attributes)?; diff --git a/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs index 29d18d8d..bab4c81c 100644 --- a/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs +++ b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs @@ -33,6 +33,19 @@ pub(super) fn build( .collect(), ), }, + TreeMetadata::BlendSpace(source) => { + use skate_core::animation::playback_tree::blend_space::{BlendSpace, Simplex}; + PlaybackTree::BlendSpace(BlendSpace::new( + source.parameters.iter().map(|p| encode(p.as_bytes())).collect(), + source.children.iter().map(|child| build(metadata, child, construction, parents)).collect::>()?, + source.simplexes.iter().map(|s| Simplex { + children:s.children.clone(), + vertices:s.vertex_bits.iter().map(|v| v.iter().map(|&v| f32::from_bits(v)).collect()).collect(), + normals:s.normal_bits.iter().map(|v| v.iter().map(|&v| f32::from_bits(v)).collect()).collect(), + scales:s.scale_bits.iter().map(|&v| f32::from_bits(v)).collect(), + }).collect(), + )?) + } TreeMetadata::PhaseBlend(source) => PlaybackTree::PhaseBlend(PhaseBlend::new( encode(source.parameter.as_bytes()), source diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs index a09406f1..763a84ee 100644 --- a/crates/skate-game/src/graph_host/motion_execute.rs +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -89,6 +89,18 @@ impl MotionHost { //Retail ctor82BA58E8 retains only diagnostic text/layout. All three //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. (MotionOperation::PrintText2D, _) => {} + (MotionOperation::SetBumpCoefficients(names), _) => { + // Begin only: vtable823200C8+48; Update/End are82B61BB8. + if phase == 0 { + use skate_core::animation::playback_parameters::{AttributeSink, SettableAttribute}; + let acceleration=self.bump_acceleration.ok_or("SetBumpCoefficients requires PhysOutAnimation112")?; + let flags=self.animation.skater_animation_flags.ok_or("SetBumpCoefficients requires live ISkaterAnim stance")?; + let values=skate_core::animation::bump::coefficients(acceleration,flags & 0x4000_0000 != 0,&self.bump_settings); + for (name,value) in names.into_iter().zip(values) { + AttributeSink::set_attribute(&mut self.animation,SettableAttribute {name,value,normalized:false,sequence_id:-1}); + } + } + } (MotionOperation::Shove(operation), Instance::Shove(state)) => match phase { 0 => state.begin(), 1 => state.update( diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs index e91637fa..3ddebfec 100644 --- a/crates/skate-game/src/graph_host/motion_nodes.rs +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -25,6 +25,7 @@ pub enum MotionOperation { Push(PushOperation), /// Angle, direction, quickness, speed, holding, in native emission order. SetTurning([AttributeName; 5]), + SetBumpCoefficients([AttributeName; 2]), ApplyingBodyTilt, SettingBodyTilt(AttributeName), Crouching(AttributeName), @@ -93,6 +94,7 @@ impl OperationFactory for MotionFactory { Some(MotionOperation::Shove(operation)) } else { match name { + "SetBumpCoefficients" => Some(MotionOperation::SetBumpCoefficients([key(a,"X",""),key(a,"Y","")])), "ResetSkaterAnimation" | "ResetToGivenStance" => Some(MotionOperation::ResetAnimation( super::motion_reset::Operation::parse(a).ok_or("Invalid reset operation")?, )), diff --git a/crates/skate-game/src/graph_host/tests/motion_bump.rs b/crates/skate-game/src/graph_host/tests/motion_bump.rs new file mode 100644 index 00000000..bae0a1e5 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_bump.rs @@ -0,0 +1,193 @@ +use super::*; +use skate_core::animation::{ + output::Sqt, + playback_tree::{Evaluation, PlaybackTree}, +}; + +fn finite(pose: &[Sqt]) { + assert!(!pose.is_empty()); + for (i, bone) in pose.iter().enumerate() { + assert!( + bone.scale + .iter() + .chain(&bone.rotation) + .chain(&bone.translation) + .all(|v| v.is_finite()), + "nonfinite bump bone {i}" + ); + } +} + +#[test] +#[ignore = "requires the user's private stock animation and graph assets"] +fn stock_bump_crash_regression() { + use crate::{animation_pose::PoseEvaluator, graph_runtime::CompiledGraph}; + use skate_data::{ + animation_banks::AnimationBanks, + state_graph::{StateGraph, binding::Binding}, + }; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let banks = AnimationBanks::load(&root).unwrap(); + let evaluator = PoseEvaluator::from_banks(&banks).unwrap(); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + let graph = LoadedGraph { + source, + binding, + runtime, + }; + let collections = Collections::load(&root).unwrap(); + let mut host = MotionHost::from_graph( + &graph, + &collections, + banks.metadata().unwrap(), + PlaybackContext { + is_switch: Some(false), + is_mirrored: Some(false), + board_available: Some(true), + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap(); + host.animation + .set_hierarchy( + &evaluator.frames.bone_names, + &evaluator.frames.mirror_indices, + ) + .unwrap(); + let mut tree = host.animation.build_tree("B_BUMP").unwrap(); + let PlaybackTree::BlendSpace(space) = &tree else { + panic!("B_BUMP lost its type6 topology") + }; + assert_eq!( + space.parameters, + vec![encode(b"DISTTOCOG"), encode(b"BUMPX"), encode(b"BUMPY")] + ); + assert_eq!(space.children.len(), 26); + assert_eq!(space.simplexes.len(), 48); + let parameters = space.parameters.clone(); + let points = space + .simplexes + .iter() + .flat_map(|s| { + let center = (0..3) + .map(|j| s.vertices.iter().map(|v| v[j]).sum::() / 4.) + .collect(); + s.vertices + .iter() + .cloned() + .chain(std::iter::once(center)) + .collect::>() + }) + .chain([vec![0., 0., 0.], vec![2., 2., -2.], vec![0.8, 0., 0.]]) + .collect::>(); + let evaluation = Evaluation { + cull_threshold: 0.01, + update_history: true, + }; + //Decode every source clip and evaluate authored vertices/interiors. No physics or gameplay automation. + for child in &space.children { + let mut child = child.clone(); + child.set_time(child.length() * 0.4); + let mut commands = Vec::new(); + child.evaluate(evaluation, true, &mut commands).unwrap(); + finite(&evaluator.evaluate(&commands).unwrap()); + } + for point in &points { + tree.set_attributes( + ¶meters + .iter() + .zip(point) + .map(|(&name, &value)| SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }) + .collect::>(), + ) + .unwrap(); + tree.set_time(tree.length() * 0.4); + let mut commands = Vec::new(); + tree.evaluate(evaluation, true, &mut commands).unwrap(); + let pose = evaluator.evaluate(&commands).unwrap(); + finite(&pose); + for bone in &pose { + let norm: f32 = bone.rotation.iter().map(|v| v * v).sum(); + assert!((norm - 1.).abs() < 1e-5, "weighted quaternion norm {norm}"); + } + tree.attributes(15).unwrap(); + let PlaybackTree::BlendSpace(space) = &tree else { + unreachable!() + }; + let weights = space.active_weights().map(|(_, w)| w).collect::>(); + assert!(weights.iter().all(|&w| w >= 0. && w <= 1.)); + if points[..240].iter().any(|p| p == point) { + let simplex = space + .simplexes + .iter() + .find(|s| { + s.children + .iter() + .copied() + .eq(space.active_weights().map(|(i, _)| i)) + }) + .unwrap(); + for j in 0..3 { + let actual = simplex + .vertices + .iter() + .zip(&weights) + .map(|(v, w)| v[j] * w) + .sum::(); + assert!( + (actual - point[j]).abs() < 2e-5, + "authored point {point:?} reconstructed as {actual} on axis {j}" + ); + } + } + assert!((weights.iter().sum::() - 1.).abs() < 1e-6); + } + //Exact runtime behavior IDs in the user's crash log. + assert!( + matches!(&host.operations[host.remap.behaviors[3623]], MotionOperation::Play(p) if p.animation.eq_ignore_ascii_case("B_BUMP")) + ); + assert!( + matches!(&host.operations[host.remap.behaviors[3624]], MotionOperation::SetBumpCoefficients(names) if *names==[encode(b"BUMPX"),encode(b"BUMPY")]) + ); + let frame = Frame { + dt: 1. / 60., + current: None, + last: None, + state_times: Vec::new(), + }; + for mirrored in [false, true] { + host.animation.reset_from_stock(); + host.animation.skater_animation_flags = Some(if mirrored { 0x4000_0000 } else { 0 }); + host.bump_acceleration = Some([150., 0., -150., 0.]); + host.execute(3623, &frame, 0).unwrap(); + host.execute(3624, &frame, 0).unwrap(); + host.animation.apply_parameters().unwrap(); + host.animation.advance(1. / 60., 0.); + host.animation.refresh_tree_attributes().unwrap(); + finite( + &evaluator + .evaluate(&host.animation.evaluate_pose(evaluation).unwrap()) + .unwrap(), + ); + //Update/End must not recapture the impact. + host.bump_acceleration = None; + host.execute(3624, &frame, 1).unwrap(); + host.execute(3624, &frame, 2).unwrap(); + } + println!( + "Bump regression: 26 stock clips, 48 simplexes, {} parameter probes, logged graph behaviors 3623/3624 in both stances", + points.len() + ); +} diff --git a/crates/skate-game/src/graph_host/tests/motion_stock.rs b/crates/skate-game/src/graph_host/tests/motion_stock.rs index 851d42d0..6abde3bb 100644 --- a/crates/skate-game/src/graph_host/tests/motion_stock.rs +++ b/crates/skate-game/src/graph_host/tests/motion_stock.rs @@ -45,6 +45,7 @@ fn stock_motion_host_loads_graph_settings_and_authored_riding_tree() { .iter() .map(count) .fold((0, 1), |(ac, ap), (bc, bp)| (ac + bc, ap + bp)), + PlaybackTree::BlendSpace(tree) => tree.children.iter().map(count).fold((0,0), |(ac,ap),(bc,bp)| (ac+bc,ap+bp)), PlaybackTree::Transition(_) => { panic!("Authored bank unexpectedly contains a runtime transition") } diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index ca1180dd..3c8ff683 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -32,6 +32,7 @@ pub(crate) fn advance( let deck = physics.board.part_transforms()[BodyId::Deck.index()]; let (fakie, mirrored) = skater.animation.stance(); let feedback = skater.physical_feedback; + skater.animation.motion.bump_acceleration = Some(feedback.ground_acceleration); skater.animation.motion.wipeout_physical = Some(crate::graph_host::motion_wipeout::Physical { over_599: physical.skeleton.over_599 != 0, collision_time_144: physical.animation.collision_time_144, diff --git a/docs/BUMP-CRASH-FIX.md b/docs/BUMP-CRASH-FIX.md new file mode 100644 index 00000000..e158ed92 --- /dev/null +++ b/docs/BUMP-CRASH-FIX.md @@ -0,0 +1,67 @@ +# Bump reaction crash, 2026-09-06 + +The user's `logs/game-20260906-041143.stderr.log` stops at animation tick 4159, +MotionGraph behaviors 3623 and 3624. `B_BUMP` was rejected as unsupported tree +type 6, followed by unsupported `SetBumpCoefficients`. Map loading and character +initialization had already succeeded. + +The implementation now reads and evaluates Andale BlendSpace records and binds +the missing Begin behavior. It retains the stock children, authored simplex +planes, coefficients, attribute operations and phase-coupled clip clocks. +It does not suppress either error by skipping the reaction or change movement, +collision, difficulty, or map settings. + +## Evidence + +Static analysis used the TU3 community IDA database copied into ignored +`logs/bump-analysis.i64`; the source database was not modified. Generated PPC +instructions were cross-checked in the local `SK8R15/Source/generated` recomp. + +- `82BC7318`, vtable `823200C8`: constructor reads X/Y FastStrings; Begin at + slot +48 is `82BB0660`, Update/End are empty `82B61BB8`. +- `82BB0660`: takes conditioned PhysOutAnimation +112, scales X, computes + direction and magnitude, maps the stock min/max bump magnitude into the stock + minimum blend value through 1, and reverses both coefficients for live + ISkaterAnim virtual +28 (mirrored stance). Emits X and Z as X/Y parameters. + All four settings come from `anim_motion/bumps`. The normalization threshold + at `830BD350` is initialized by `82F826F8` to `1e-6`, not its on-disk BSS zero. +- `82D22DA0`, `82D23898`: type 6 layout, serialized simplex vertices, normals, + inverse heights and child indices. Stock `B_BUMP` has 26 children, 48 + tetrahedra and parameters `DISTTOCOG`, `BUMPX`, `BUMPY`. +- `82D23080`, `82D23530`, `82D23A50`: original scalar solver, including ordered + plane slicing and clamped barycentric reprojection outside the authored hull. + The host uses this scalar path for three dimensions as well. It does not + recreate the original accelerated job submission. +- `82D24240`, `82D24580`, `82D16370`, `82D16428`: weighted attribute intersection, + scale and accumulation. The native single-attribute query's different child + indexing is retained. +- `82D246A8`, `82D24730`, `82D24AB0`, `82D24B28`: weighted length and synchronized + child advance/time/speed operations. +- `82D23EF8`, immediate ACS `828CBF68..828CC0B4`: accumulate weighted SQT poses + in source order, then normalize rotation once. The original multi-pose path + does not use the two-pose shortest-arc hemisphere decision. + +Rust scalar/VMX-style arithmetic follows the recovered instruction order where +represented; native reciprocal-square-root estimate helpers are not a claim of +bit-exact Xenon execution. No console trace or visual parity claim is made. + +## Verification + +Focused core tests cover coefficient thresholds, axis scaling, mirrored stance, +plane projection, clip synchronization and multi-pose quaternion accumulation. +The private-stock regression decodes all 26 clips, evaluates all authored simplex +vertices and interiors (243 parameter probes including outside-hull points), +checks finite weighted poses and reconstructs authored parameter positions. +It executes the exact logged graph behaviors 3623/3624 in both mirrored states +through parameter application, pose generation and attribute publication. + +Run the stock regression with `SKATE3_ASSET_ROOT` pointing to this repository's +`assets` directory: + +```powershell +cargo test -p skate-game stock_bump_crash_regression --locked --offline -- --ignored --nocapture +``` + +Gameplay and visual testing remain with the user. The earlier separately logged +`AddRunoutAttribs` behavior 86 remains unsupported; this patch repairs the later +bump crash and does not claim every imported controller path is complete.