Implement stock bump reaction blend space and graph behavior

This commit is contained in:
chazmm committed 2026-09-06 04:42:07 +01:00
1 parent 792056ca8e
commit b4f5b67d5d
22 files changed
+1049 -2

No files matched your search

+69
View File
@@ -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);
}
}
+1
View File
@@ -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;
@@ -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<AnimationAttribute>, right: &[AnimationAttribute], weight: f32) -> Result<Vec<AnimationAttribute>, String> {
@@ -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<f32> },
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<bool, String> {
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<bool, String> {
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)
@@ -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<usize>,
pub vertices: Vec<Vec<f32>>,
pub normals: Vec<Vec<f32>>,
pub scales: Vec<f32>,
}
impl Simplex {
///82D23898: signed distances to the opposite planes, scaled by inverse height.
pub fn coordinates(&self, point: &[f32]) -> Vec<f32> {
(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<f32> {
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<AttributeName>,
pub children: Vec<PlaybackTree>,
pub simplexes: Vec<Simplex>,
values: Vec<f32>,
current: usize,
weights: Vec<f32>,
pub time: f32,
}
impl BlendSpace {
pub fn new(
parameters: Vec<AttributeName>,
children: Vec<PlaybackTree>,
simplexes: Vec<Simplex>,
) -> Result<Self, String> {
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<Item = (usize, f32)> + '_ {
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<bool, String> {
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<Vec<AnimationAttribute>, 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<bool, String> {
//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<PoseCommand>,
) -> Result<bool, String> {
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]
})
);
}
}
@@ -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<Sqt>], weights: &[f32]) -> Result<Vec<Sqt>, 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::<Vec<_>>();
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;
@@ -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::<f32>().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],
+19 -2
View File
@@ -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<PhaseBlendMetadata>,
#[serde(default)]
blend_spaces: Vec<BlendSpaceMetadata>,
#[serde(default)]
selectors: Vec<SelectorMetadata>,
#[serde(default)]
selection_spaces: Vec<SelectionSpaceMetadata>,
@@ -98,6 +103,7 @@ pub struct AnimationMetadata {
pub source_sha256: String,
clips: BTreeMap<String, Vec<ClipMetadata>>,
phase_blends: BTreeMap<String, Vec<PhaseBlendMetadata>>,
blend_spaces: BTreeMap<String, Vec<BlendSpaceMetadata>>,
selectors: BTreeMap<String, Vec<SelectorMetadata>>,
selection_spaces: BTreeMap<String, Vec<SelectionSpaceMetadata>>,
unsupported: BTreeMap<String, Vec<(u64, u32)>>,
@@ -191,6 +197,11 @@ impl AnimationMetadata {
.or_default()
.push(tree);
}
let mut blend_spaces: BTreeMap<String, Vec<BlendSpaceMetadata>> = 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<String, Vec<SelectorMetadata>> = 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));
}
@@ -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<usize>,
/// Spatial coordinates only; the serialized matrix also has a final column.
pub vertex_bits: Vec<Vec<u32>>,
pub normal_bits: Vec<Vec<u32>>,
pub scale_bits: Vec<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BlendSpaceMetadata {
pub name: String,
pub source_offset: u64,
pub parameters: Vec<String>,
pub children: Vec<String>,
pub simplexes: Vec<BlendSimplexMetadata>,
}
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<BlendSpaceMetadata> {
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::<Result<_>>()?,
);
simplex.normal_bits.push(
(0..d)
.map(|j| s.u32(normal + 4 + j * 4))
.collect::<Result<_>>()?,
);
}
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::<Result<_>>()?,
children: (0..count)
.map(|i| r.name(names + i * 24, 6))
.collect::<Result<_>>()?,
simplexes,
})
}
@@ -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())?),
+6
View File
@@ -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
@@ -151,6 +151,8 @@ pub struct MotionHost {
wipeout_settings: super::motion_wipeout::Settings,
///Native PhysOutGround pumping acceleration268.
pub pumping_acceleration: Option<f32>,
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<String>,
@@ -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;
@@ -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
@@ -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)?;
@@ -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::<Result<_,_>>()?,
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
@@ -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(
@@ -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")?,
)),
@@ -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::<f32>() / 4.)
.collect();
s.vertices
.iter()
.cloned()
.chain(std::iter::once(center))
.collect::<Vec<_>>()
})
.chain([vec![0., 0., 0.], vec![2., 2., -2.], vec![0.8, 0., 0.]])
.collect::<Vec<_>>();
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(
&parameters
.iter()
.zip(point)
.map(|(&name, &value)| SettableAttribute {
name,
value,
normalized: false,
sequence_id: -1,
})
.collect::<Vec<_>>(),
)
.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::<Vec<_>>();
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::<f32>();
assert!(
(actual - point[j]).abs() < 2e-5,
"authored point {point:?} reconstructed as {actual} on axis {j}"
);
}
}
assert!((weights.iter().sum::<f32>() - 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()
);
}
@@ -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")
}
@@ -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,