Import supplied skater project and standalone Windows setup

This commit is contained in:
chazmm committed 2026-09-06 03:44:14 +01:00
commit a47da5bb74
816 files changed
+105573

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
/target/
/bin/
/logs/
/assets/private/
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Build.ps1"
if errorlevel 1 pause
+53
View File
@@ -0,0 +1,53 @@
param([switch]$StageOnly, [string]$TargetDirectory = (Join-Path $PSScriptRoot 'target'))
$ErrorActionPreference = 'Stop'
Push-Location $PSScriptRoot
try {
if (-not $StageOnly) {
& cargo build -p skate-game --locked --target-dir $TargetDirectory
if ($LASTEXITCODE -ne 0) { throw 'Build failed; see the compiler output above.' }
}
$debugDirectory = Join-Path $TargetDirectory 'debug'
$executable = Join-Path $debugDirectory 'skate-game.exe'
if (-not (Test-Path -LiteralPath $executable)) { throw "Missing executable: $executable" }
$readobj = Join-Path $env:ProgramFiles 'LLVM/bin/llvm-readobj.exe'
if (-not (Test-Path -LiteralPath $readobj)) { throw 'LLVM llvm-readobj is required to stage exact runtime DLL dependencies.' }
$rustLibraries = (& rustc --print target-libdir).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Could not locate Rust runtime libraries.' }
$binDirectory = Join-Path $PSScriptRoot 'bin'
New-Item -ItemType Directory -Path $binDirectory -Force | Out-Null
$queue = [System.Collections.Generic.Queue[string]]::new()
$queue.Enqueue($executable)
$seen = @{}
$staged = @()
while ($queue.Count -gt 0) {
$source = $queue.Dequeue()
$name = Split-Path -Leaf $source
if ($seen.ContainsKey($name)) { continue }
$seen[$name] = $true
$destination = Join-Path $binDirectory $name
Copy-Item -LiteralPath $source -Destination $destination -Force
$staged += @{name = $name; sha256 = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash}
$imports = & $readobj --coff-imports $source
if ($LASTEXITCODE -ne 0) { throw "Could not inspect DLL imports: $source" }
foreach ($line in $imports) {
if ($line -match '^\s+Name: (.+\.dll)$') {
$dependency = $Matches[1]
$found = $false
foreach ($directory in @($debugDirectory, (Join-Path $debugDirectory 'deps'), $rustLibraries)) {
$candidate = Join-Path $directory $dependency
if (Test-Path -LiteralPath $candidate) {
$queue.Enqueue($candidate)
$found = $true
break
}
}
if (-not $found -and $dependency -notmatch '^(api-ms-|ext-ms-)' -and
-not (Test-Path -LiteralPath (Join-Path $env:WINDIR "System32/$dependency"))) {
throw "Runtime dependency not found: $dependency"
}
}
}
}
$staged | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $binDirectory 'manifest.json') -Encoding UTF8
Write-Host "Ready: $binDirectory/skate-game.exe"
} finally { Pop-Location }
Generated
+5937
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
[workspace]
members = ["crates/skate-core", "crates/skate-data", "crates/skate-game"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
publish = false
[workspace.dependencies]
bevy = { version = "=0.18.1", features = ["dynamic_linking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
skate-core = { path = "crates/skate-core" }
skate-data = { path = "crates/skate-data" }
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
+19
View File
@@ -0,0 +1,19 @@
$ErrorActionPreference = 'Stop'
Push-Location $PSScriptRoot
try {
$executable = Join-Path $PSScriptRoot 'bin/skate-game.exe'
if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' }
New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot 'logs') -Force | Out-Null
$log = Join-Path $PSScriptRoot ('logs/game-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log')
Write-Host 'Starting imported skater project (Easy). Use your XInput controller; Esc exits.'
Write-Host "Log: $log"
$errorLog = [System.IO.Path]::ChangeExtension($log, 'stderr.log')
$assetArgument = '"' + (Join-Path $PSScriptRoot 'assets') + '"'
$game = Start-Process -FilePath $executable -WorkingDirectory $PSScriptRoot `
-ArgumentList @('--assets', $assetArgument) -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $log -RedirectStandardError $errorLog
if ($game.ExitCode -ne 0) {
Get-Content -LiteralPath $errorLog -Tail 30
throw "Game exited with code $($game.ExitCode). Log: $errorLog"
}
} finally { Pop-Location }
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1"
if errorlevel 1 pause
+45
View File
@@ -0,0 +1,45 @@
# Imported skater project
Double-click **PLAY.bat** to launch. Use an XInput-compatible controller;
**Esc** exits. Startup selects the supplied **Easy** profile.
This folder is separate from the earlier clone. It contains the contents of
`crates (1).zip` and `assets.zip`, a reconstructed Cargo workspace, and launch
scripts. The supplied stock animation banks, decoded frames, graphs, skater
mesh and textures are used together. No previous-clone animation code was
needed for setup.
The supplied scene contains a small test environment. Gameplay quality and
Skate 3 parity remain for the user to assess; compiling and starting the
project do not establish those claims.
## Observed runtime issue
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
`AddRunoutAttribs` (behavior 86). Both startup sessions recorded that exact
unsupported-behavior error. This is an implementation gap, not a missing
animation-file error. It has not been bypassed or replaced with guessed behavior.
See `logs/game-20260906-033940.stderr.log` for the character-ready message
and subsequent failure. No automated gameplay or screenshot test was run.
## Files
- `crates/`: imported Rust source. The two startup profile selections were
changed from `normal` to `easy`.
- `assets/private/`: supplied assets; keep these private.
- `bin/`: staged executable and its exact Rust/Bevy runtime DLL dependencies.
- `logs/`: per-launch output and error logs.
- `Cargo.lock`: resolved dependency versions for reproducible rebuilds.
- `SETUP-PROVENANCE.json`: original ZIP hashes and setup changes.
**BUILD.bat** rebuilds and stages the executable. It requires Cargo/Rust and
LLVM `llvm-readobj` at the installed Windows location. The initial build
reused the earlier project's compilation cache; PLAY.bat uses only this
folder's staged binary, libraries and assets. Subsequent default builds use
this folder's own `target/` directory.
If startup fails, the launcher keeps the console open and shows the error
log path. Do not substitute animation files to silence a missing-data error;
record the exact error first.
+24
View File
@@ -0,0 +1,24 @@
{
"archives": {
"crates (1).zip": "d039af756525a3071178c366bf6d44a8b426f320e28c414cb34449240be89bd9",
"assets.zip": "5127c4608742c4c2f574edb128829c6cc22f02cf957b9ade2fae6c56e5b95f7b"
},
"setup_changes": [
"Added missing Cargo workspace using installed Bevy 0.18.1 and Rust 2024 edition.",
"Selected supplied Easy profile at both game startup load sites.",
"Added build, staging and launch scripts."
],
"animation_source": "Supplied assets.zip; no assets from the previous clone required.",
"validation": {
"build": "passed (56 supplied-source warnings)",
"startup": "GAME_CHARACTER_READY bones=35",
"skin_bones_matched": 35,
"decoded_animation_clips": 487,
"runtime_issue": "Unsupported MotionGraph Behavior AddRunoutAttribs (behavior 86); no bypass applied",
"gameplay_controls": "user-driven; no injected input or automated visual test"
},
"modified_imported_files": [
"crates/skate-game/src/main.rs",
"crates/skate-game/src/physics.rs"
]
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "skate-core"
version.workspace = true
edition.workspace = true
publish.workspace = true
+107
View File
@@ -0,0 +1,107 @@
//! TU3 Reckoning::UpdateBodyFlip 82D8EC70, with explicit attribute-read inputs.
//! Full scalar gates, spin matrix and conditional combined-matrix publication.
use crate::trigonometry::sin_cos;
pub type Matrix = [[f32; 4]; 4];
#[derive(Clone, Debug, PartialEq)]
pub struct BodyFlipState {
/// Reckoning +1584, +1588, +1592.
pub angle: f32,
pub speed: f32,
pub requested_speed: f32,
/// Reckoning +1072 and +1008. These are distinct native output buffers.
pub spin_transform: Matrix,
pub combined_transform: Matrix,
}
/// Actual Attrib::GetAttributePointer results (82B72420), not guessed settings.
/// Missing collection/attribute resolves to the supplied live 830D0850 value.
pub struct BodyFlipSettings {
/// Hash 26E7322CCDC3FE23.
pub smoothing: Option<f32>,
/// Hash 3D95C06DC6E3A002.
pub maximum_speed: Option<f32>,
/// Hash 89763E47712871C3.
pub spin_scale: Option<f32>,
pub missing_attribute_value: f32,
}
pub struct BodyFlipInput {
pub requested_speed: f32,
/// Reckoning +1568, +1152 and +1232; caller-provided axes, without repair.
pub spin_angle: f32,
pub normal: [f32; 4],
pub flip_axis: [f32; 4],
/// Processed +2604; only the non-perfect branch uses this dt.
pub timestep: f32,
/// Processed +2548 -> layout +4 -> byte +28.
pub perfect_body_flips: bool,
}
pub fn update(state: &mut BodyFlipState, settings: &BodyFlipSettings, input: &BodyFlipInput) {
let fallback = settings.missing_attribute_value;
let smoothing = settings.smoothing.unwrap_or(fallback);
let maximum = settings.maximum_speed.unwrap_or(fallback);
let spin_scale = settings.spin_scale.unwrap_or(fallback);
state.requested_speed = input.requested_speed;
let mut speed = (input.requested_speed - state.speed).mul_add(smoothing, state.speed);
if input.perfect_body_flips {
let full_turn = f32::from_bits(0x40C9_0FDB);
let remaining_rate = (full_turn - state.angle.abs()) * 12.0;
let sign = if speed > 0.0 { 1.0 } else { -1.0 };
if !(speed.abs() < remaining_rate) {
speed = sign * remaining_rate;
}
state.angle = clamp(
speed.mul_add(f32::from_bits(0x3C88_8889), state.angle),
-full_turn,
full_turn,
);
} else {
speed = clamp(speed, -maximum, maximum);
state.angle = input.timestep.mul_add(speed, state.angle);
}
state.speed = speed;
state.spin_transform = rotation(input.normal, input.spin_angle * spin_scale);
// Native zero-angle branch leaves the +1008 matrix untouched.
if state.angle != 0.0 {
let flip = rotation(input.flip_axis, state.angle);
let spin = state.spin_transform;
for col in 0..4 {
for lane in 0..4 {
let first = if col == 3 {
spin[col][0].mul_add(flip[0][lane], flip[3][lane])
} else {
spin[col][0] * flip[0][lane]
};
let second = spin[col][1].mul_add(flip[1][lane], first);
state.combined_transform[col][lane] = spin[col][2].mul_add(flip[2][lane], second);
}
}
}
}
fn clamp(value: f32, lower: f32, upper: f32) -> f32 {
let value = if lower - value >= 0.0 { lower } else { value };
if upper - value >= 0.0 { value } else { upper }
}
fn rotation(axis: [f32; 4], angle: f32) -> Matrix {
let [x, y, z, _] = axis;
let (s, c) = sin_cos(angle);
let t = 1.0 - c;
let (tx, ty, tz) = (t * x, t * y, t * z);
let (sx, sy, sz) = (s * x, s * y, s * z);
let xx = tx.mul_add(x, c);
let yx = ty * x - sz;
let zx = tz.mul_add(x, sy);
// The native 822FB890 permute repeats column.x into lane w. It does not
// create a renderer-style affine matrix with zero w basis lanes.
[
[xx, tx.mul_add(y, sz), tx * z - sy, xx],
[yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx],
[zx, tz * y - sx, tz.mul_add(z, c), zx],
[0.0; 4],
]
}
+203
View File
@@ -0,0 +1,203 @@
//! Complete TU3 BodySpin::Update (82D8BF58) and history scan (82D8C2D8).
//! Cached curves are explicit inputs, not reconstructed from Skate 2 settings.
use crate::point_graph::PointGraph;
/// Native bytes +0..176. Preserves the unused +136 word and mode-byte padding.
/// Construction/air-entry lifecycle belongs to the recovered caller, not Default.
#[derive(Clone, Debug, PartialEq)]
pub struct BodySpinState {
words: [u32; 44],
}
impl BodySpinState {
///82D8DA98..DAE0 initializes every BodySpin scalar and history entry.
pub fn new() -> Self {
Self { words: [0; 44] }
}
pub fn from_words(words: [u32; 44]) -> Self {
assert!(
words[42] < 30,
"native derivative history index must be 0..30"
);
Self { words }
}
pub fn words(&self) -> &[u32; 44] {
&self.words
}
/// +140, consumed by Reckoning::UpdateAirStates after BodySpin::Update.
pub fn speed(&self) -> f32 {
self.get(140)
}
fn get(&self, offset: usize) -> f32 {
f32::from_bits(self.words[offset / 4])
}
fn set(&mut self, offset: usize, value: f32) {
self.words[offset / 4] = value.to_bits();
}
}
/// Native cached inputs at +176/+180, seven 80-byte curves, and 830BD300.
/// The vector threshold's producer must be supplied: no guessed retail default.
pub struct BodySpinSettings {
pub derivative_floor: f32,
pub acceleration_limit: f32,
/// Native +184, +264, +344, +424, +504, +584, +664; x/y at +16/+48.
pub curves: [PointGraph<8>; 7],
pub input_fade_threshold: [f32; 4],
}
const STEP: f32 = f32::from_bits(0x3C88_8889); // 820849C8, fixed in these functions.
/// Registers f1/f2/r6/r7, including the native mode-byte publication.
pub fn update(
state: &mut BodySpinState,
settings: &BodySpinSettings,
input: f32,
auto_spin: f32,
in_air: bool,
mode: u8,
) {
update_input(state, input, auto_spin, mode);
if !in_air {
finish_ground(state, input);
return;
}
update_air(state, settings, input, auto_spin, mode);
}
/// Ground caller82D8C8F0 supplies auto_spin=0, in_air=false and mode=0.
/// This branch does not read any of the airborne BodySpin tuning curves.
pub fn update_ground(state: &mut BodySpinState, input: f32) {
update_input(state, input, 0.0, 0);
finish_ground(state, input);
}
fn update_input(state: &mut BodySpinState, input: f32, auto_spin: f32, mode: u8) {
let delta = input - state.get(120);
let derivative = delta.mul_add(1.5, state.get(124) * 0.0);
state.set(164, auto_spin);
state.set(120, input);
state.words[43] = (state.words[43] & 0x00FF_FFFF) | ((mode as u32) << 24);
state.set(124, derivative);
let filtered = if derivative * input > 0.1 {
derivative
} else {
0.0
};
let filtered = native_clamp(filtered, -1.0, 1.0);
state.set(128, filtered);
let index = state.words[42] as usize;
state.set(index * 4, filtered);
state.words[42] = ((index + 1) % 30) as u32;
}
fn finish_ground(state: &mut BodySpinState, input: f32) {
state.set(152, 0.0);
state.set(144, 0.0);
state.set(140, 0.0);
state.set(132, state.get(132).mul_add(0.8, input * 0.2));
}
fn update_air(
state: &mut BodySpinState,
settings: &BodySpinSettings,
input: f32,
auto_spin: f32,
mode: u8,
) {
let alternate = usize::from(mode == 0);
if state.get(152) == 0.0 {
calculate_history_derivative(state, &settings.curves[3 + alternate]);
}
let time = state.get(152);
let normal_acceleration = settings.curves[alternate].evaluate(time);
let auto_acceleration = settings.curves[2].evaluate(time);
let derivative_gain = settings.curves[3 + alternate].evaluate(time);
let candidate = derivative_gain * state.get(128);
if candidate.abs() > state.get(148).abs() {
state.set(148, candidate);
state.set(156, time);
}
let next_time = time + STEP;
state.set(152, next_time);
// vcmpgtfp128. followed by CR6 all-lanes test, not a scalar dead zone.
if settings
.input_fade_threshold
.iter()
.all(|&v| v > state.get(120).abs())
{
let faded = state.get(132) * 0.96;
state.set(132, faded);
state.set(120, faded);
} else {
state.set(132, state.get(132).mul_add(0.8, input * 0.2));
}
let proportional = settings.curves[5 + alternate].evaluate(next_time) * state.get(120);
state.set(144, proportional);
if proportional * state.get(148) < 0.0 {
state.set(148, 0.0);
}
let weight = state
.get(148)
.abs()
.mul_add(1.0 - settings.derivative_floor, settings.derivative_floor);
let mut target = -(weight * proportional);
let mut acceleration = normal_acceleration;
let old_speed = state.get(140);
if auto_spin.abs() > f32::from_bits(0x3780_0000)
&& (old_speed * auto_spin > 0.0 || old_speed.abs() < 0.02)
{
acceleration = auto_acceleration;
target = native_clamp(auto_spin, -2.0, 2.0);
}
let limited = select(
settings.acceleration_limit - acceleration,
acceleration,
settings.acceleration_limit,
);
let (lower, upper) = if old_speed > 0.0 {
(-limited, acceleration)
} else {
(-acceleration, limited)
};
let change = native_clamp(target - old_speed, lower, upper);
let speed = old_speed + change;
state.set(140, speed);
// Both subtractions are present at 82D8C27C..284.
state.set(160, (target - speed) - change);
}
/// 82D8C2D8 scans the 29 entries before the current write position.
/// The newest entry starts at negative one native step, with strict peak ties.
fn calculate_history_derivative(state: &mut BodySpinState, graph: &PointGraph<8>) {
let end = state.words[42] as usize;
let mut index = (end + 29) % 30;
let mut time = 0.0;
let mut peak: f32 = 0.0;
state.set(148, 0.0);
state.set(156, 0.0);
while index != end {
time -= STEP;
let candidate = graph.evaluate(time) * state.get(index * 4);
if candidate.abs() > peak.abs() {
peak = candidate;
state.set(156, time);
}
index = (index + 29) % 30;
}
state.set(148, peak);
}
fn select(test: f32, nonnegative: f32, negative: f32) -> f32 {
if test >= 0.0 { nonnegative } else { negative }
}
fn native_clamp(value: f32, lower: f32, upper: f32) -> f32 {
let value = select(lower - value, lower, value);
select(upper - value, value, upper)
}
@@ -0,0 +1,65 @@
//! Original arithmetic ordering and two estimate-refinement steps.
use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic};
type Vector = [f32; 4];
pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0];
pub(super) fn dot(a: Vector, b: Vector) -> f32 {
native_arithmetic::dot3(a, b)
}
pub(super) fn add(a: Vector, b: Vector) -> Vector {
std::array::from_fn(|i| a[i] + b[i])
}
pub(super) fn sub(a: Vector, b: Vector) -> Vector {
std::array::from_fn(|i| a[i] - b[i])
}
pub(super) fn scale(a: Vector, b: f32) -> Vector {
a.map(|v| v * b)
}
pub(super) fn madd(a: Vector, b: f32, c: Vector) -> Vector {
std::array::from_fn(|i| a[i].mul_add(b, c[i]))
}
pub(super) fn planar(value: Vector, normal: Vector) -> Vector {
sub(value, scale(normal, dot(value, normal)))
}
pub(super) fn maximum(a: f32, b: f32) -> f32 {
if a - b >= -0.0 { a } else { b }
}
pub(super) fn clamp(v: f32, low: f32, high: f32) -> f32 {
let lower = if low - v >= -0.0 { low } else { v };
if high - lower >= -0.0 { lower } else { high }
}
pub(super) fn vector_clamp(v: f32, low: f32, high: f32) -> f32 {
native_arithmetic::vector_min(high, native_arithmetic::vector_max(low, v))
}
pub(super) fn reciprocal(v: f32) -> f32 {
let mut r = native_arithmetic::reciprocal_estimate(v);
for _ in 0..2 {
r = r.mul_add((-r).mul_add(v, 1.0), r);
}
r
}
pub(super) fn square_root(square: f32) -> f32 {
let value = square * inverse_length_squared(square, 2);
if square == 0.0 { 0.0 } else { value }
}
pub(super) fn length(value: Vector) -> f32 {
square_root(dot(value, value))
}
pub(super) fn normalize(value: Vector) -> Vector {
let square = dot(value, value);
let inverse = inverse_length_squared(square, 2);
let length = if square == 0.0 { 0.0 } else { square * inverse };
//82F826F8 initializes830BD350 from82181A88 (1e-6), not its IDA BSS zero.
if length > f32::from_bits(0x3586_37bd) {
scale(value, inverse)
} else {
[0.0; 4]
}
}
pub(super) fn cross(a: Vector, b: Vector) -> Vector {
[
(-a[2]).mul_add(b[1], a[1] * b[2]),
(-a[0]).mul_add(b[2], a[2] * b[0]),
(-a[1]).mul_add(b[0], a[0] * b[1]),
0.0,
]
}
@@ -0,0 +1,77 @@
//! GroundAnimation's Toolkit_CalcGroundJump, original TU3 82D93618.
//! Stock graph values and live animation/physical inputs determine launch.
mod math;
mod types;
use crate::{
air::state::{AirMath, PhysicsAirMath},
trigonometry::acos,
};
use math::*;
pub use types::{GroundJump, GroundJumpInput, GroundJumpMode, GroundJumpSettings};
pub fn calculate(
input: GroundJumpInput,
mode: GroundJumpMode,
s: &GroundJumpSettings,
) -> GroundJump {
if input.flags_2468 & 0x0040_0000 == 0 {
return GroundJump::default();
}
let normal = input.filtered_ground_normal;
let y_scalar = s.y_scalar_vs_normal_y.evaluate(normal[1]);
let current = planar(input.current_velocity, normal);
let mut prepared = planar(input.prepared_velocity, normal);
if dot(sub(current, prepared), sub(current, prepared)) > 7.0 {
let agreement = vector_clamp(dot(normalize(current), normalize(prepared)), 0.0, 1.0);
let fraction = acos(agreement) * reciprocal(f32::from_bits(0x4049_0fdb));
let bound = length(prepared) * s.speed_scalar_vs_angle.evaluate(fraction);
prepared = AirMath.clamp_vector_within_max_length(current, bound);
}
let mut velocity = if input.flags_2488 & 0x1000_0000 != 0 {
let mut value = madd(input.effective_forward, 2.3, prepared);
value[1] += 1.0;
value
} else {
let minimum = if input.flags_2484 & 0x800 != 0 {
mode.minimum_height_64
} else {
mode.minimum_height_68
};
let speed_fraction = clamp(input.surface_speed / s.speed_response_max_speed, 0.0, 1.0);
let low = s.minimum_height_vs_speed.evaluate(speed_fraction).mul_add(
minimum - s.absolute_minimum_height,
s.absolute_minimum_height,
);
let high = s.maximum_height_vs_speed.evaluate(speed_fraction).mul_add(
mode.maximum_height - s.absolute_minimum_height,
s.absolute_minimum_height,
);
let height = low.mul_add(1.0 - input.jump_strength, high * input.jump_strength);
let current_height = dot(
sub(
input.animation_com_position,
input.ground_reference_position,
),
input.reference_up,
);
let remaining = maximum(height - current_height, 0.0);
let square = (remaining * input.gravity_y) * -2.0;
let speed = square_root(square);
let angle = acos(vector_clamp(normal[1], -1.0, 1.0));
let fraction = clamp(angle * f32::from_bits(0x3f22_f983), 0.0, 1.0);
let response = maximum(s.vertical_response.evaluate(fraction), s.minimum_scalar);
madd(normal, speed * response, prepared)
};
let bonus = clamp((y_scalar - 1.0) * velocity[1], 0.0, s.maximum_y_bonus);
velocity[1] += bonus;
let projected = dot(velocity, normal);
let side = normalize(cross(UP, input.forward));
let x = -((input.jump_controls[0] * s.adjust_x_factor) * projected);
let z = (input.jump_controls[1] * s.adjust_z_factor) * projected;
let correction = madd(side, x, scale(cross(side, UP), z));
GroundJump {
velocity: add(velocity, correction),
scalar_16: 0.0,
active: true,
}
}
@@ -0,0 +1,44 @@
use crate::point_graph::PointGraph;
#[derive(Clone, Copy, Debug, Default)]
pub struct GroundJump {
pub velocity: [f32; 4], //JumpInfo0, metres/second
pub scalar_16: f32, //82D93618 initializes and retains zero
pub active: bool, //JumpInfo20
}
#[derive(Clone, Copy, Debug)]
pub struct GroundJumpMode {
pub minimum_height_64: f32, //physics_mode Hash_BE3F74F978D777E5, metres
pub minimum_height_68: f32, //JumpMinHeight, metres
pub maximum_height: f32, //JumpMaxHeight72, metres
}
pub struct GroundJumpSettings {
pub vertical_response: PointGraph<16>, //physics_jump0
pub y_scalar_vs_normal_y: PointGraph<8>, //128
pub speed_scalar_vs_angle: PointGraph<8>, //208
pub minimum_height_vs_speed: PointGraph<8>, //288
pub maximum_height_vs_speed: PointGraph<8>, //352
pub speed_response_max_speed: f32, //420, metres/second
pub minimum_scalar: f32, //424
pub maximum_y_bonus: f32, //428, metres/second
pub adjust_z_factor: f32, //432
pub adjust_x_factor: f32, //436
pub absolute_minimum_height: f32, //456, metres
}
#[derive(Clone, Copy, Debug)]
pub struct GroundJumpInput {
pub flags_2468: u32,
pub flags_2484: u32,
pub flags_2488: u32,
pub effective_forward: [f32; 4], //Processed160
pub forward: [f32; 4], //352
pub current_velocity: [f32; 4], //400 metres/second
pub ground_reference_position: [f32; 4], //480 metres
pub reference_up: [f32; 4], //544
pub filtered_ground_normal: [f32; 4], //576, BoardToolkit normal192
pub animation_com_position: [f32; 4], //592 metres
pub prepared_velocity: [f32; 4], //704 metres/second
pub jump_strength: f32, //2624, animation output
pub jump_controls: [f32; 2], //2628/2632, animation output
pub gravity_y: f32, //2648 metres/second squared
pub surface_speed: f32, //2656 metres/second
}
@@ -0,0 +1,85 @@
//! TU3 Reckoning::UpdateGroundNormal 82D8E0F8, including filter 82D7A018.
use crate::physics::reciprocal_sqrt::estimate;
/// Native reckoning +1280..1376: control, position, previous input, filtered
/// error, output delta and filtered input delta. All four vector lanes persist.
/// Initial values must come from the native constructor/entry path.
#[derive(Clone, Debug, PartialEq)]
pub struct GroundNormalFilter {
words: [u32; 24],
}
impl GroundNormalFilter {
/// TU3 constructor82D8DA68 initializes current/last target to the seed,
/// and all three accumulated difference vectors to zero.
pub fn initialized(control: [f32; 4], initial: [f32; 4]) -> Self {
let mut words = [0; 24];
words[..4].copy_from_slice(&control.map(f32::to_bits));
words[4..8].copy_from_slice(&initial.map(f32::to_bits));
words[8..12].copy_from_slice(&initial.map(f32::to_bits));
Self { words }
}
/// The up-vector filters publish the final corrected up vector only after
/// both filter updates and acceleration/damping in82D8C8F0.
pub fn publish_current(&mut self, current: [f32; 4]) {
self.words[4..8].copy_from_slice(&current.map(f32::to_bits));
}
pub fn filter_raw(&mut self, control: [f32; 4], input: [f32; 4]) -> [f32; 4] {
self.words[..4].copy_from_slice(&control.map(f32::to_bits));
self.filter(input)
}
pub fn from_words(words: [u32; 24]) -> Self {
Self { words }
}
pub fn words(&self) -> &[u32; 24] {
&self.words
}
/// Copies live layout+1216 control words before each native filter call.
/// Returns the normal published at reckoning+1216 and +1296. There is no
/// invented zero-vector fallback; native degenerate arithmetic is preserved.
pub fn update(&mut self, control: [f32; 4], input: [f32; 4]) -> [f32; 4] {
self.words[..4].copy_from_slice(&control.map(f32::to_bits));
let filtered = self.filter(input);
let squared = dot3(filtered);
let mut inverse = estimate(squared);
for _ in 0..2 {
let correction = (-squared).mul_add(inverse * inverse, 1.0);
inverse = (inverse * 0.5).mul_add(correction, inverse);
}
let normal = filtered.map(|v| v * inverse);
self.words[4..8].copy_from_slice(&normal.map(f32::to_bits));
normal
}
// Full vector filter 82D7A018, with publication order before normalization.
pub(crate) fn filter(&mut self, input: [f32; 4]) -> [f32; 4] {
let previous = self.words.map(f32::from_bits);
let blend = previous[3];
let retained = 1.0 - blend;
let mut output = [0.0; 4];
for lane in 0..4 {
let error = input[lane] - previous[4 + lane];
let error_blend = previous[12 + lane].mul_add(retained, error * blend);
let input_delta = input[lane] - previous[8 + lane];
let input_blend = previous[20 + lane].mul_add(retained, input_delta * blend);
let predicted = error_blend.mul_add(previous[1], previous[4 + lane]);
let predicted = error.mul_add(previous[0], predicted);
output[lane] = (input_blend - previous[16 + lane]).mul_add(previous[2], predicted);
self.words[12 + lane] = error_blend.to_bits();
self.words[20 + lane] = input_blend.to_bits();
self.words[16 + lane] = (output[lane] - previous[4 + lane]).to_bits();
self.words[4 + lane] = output[lane].to_bits();
self.words[8 + lane] = input[lane].to_bits();
}
output
}
}
fn dot3(value: [f32; 4]) -> f32 {
crate::physics::native_arithmetic::dot3(value, value)
}
+155
View File
@@ -0,0 +1,155 @@
//! Body flip and spin targets selected by TU3 KnownAir.
use super::{
data::{
KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields, KnownAirSettings,
KnownAirState, Vector4,
},
runtime::KnownAirRuntime,
trajectory::select,
};
const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); // 0x8206D110.
const RADIANS_TO_DEGREES: f32 = f32::from_bits(0x4265_2EE1); // 0x82084620.
const DIRECTION_EPSILON: f32 = f32::from_bits(0x3A83_126F); // 0x82063A48.
const PI: f32 = f32::from_bits(0x4049_0FDB); // 0x82060C44.
const ALIGNMENT_DELAY: f32 = f32::from_bits(0x3D03_126F); // 0x822F91E8.
const AUTO_SPIN_SCALE: f32 = f32::from_bits(0x3F99_999A); // 0x821659F4.
const FORTY_FIVE_DEGREES: f32 = f32::from_bits(0x4234_0000); // 0x82099018.
const HALF: f32 = f32::from_bits(0x3F00_0000); // 0x8209975C.
const REVERSE_SCALE: f32 = f32::from_bits(0x3BA3_D70A); // 0x82099920.
const DECELERATION_SCALE: f32 = f32::from_bits(0x3F40_0000); // 0x821814A0.
/// `PhysState_KnownAir::CalculateBodyFlipSpeed`, TU3 `0x82D35F40`.
pub fn calculate_body_flip_speed(
state: &mut KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
mode: &KnownAirModeSettings,
reckoning: &KnownAirReckoningFields,
runtime: &mut impl KnownAirRuntime,
) -> f32 {
if !state.body_flipping_211 {
let flags = frame.flags_2468;
let has_flip = flags & 0x0000_0020 != 0 || flags & 0x0000_0010 != 0;
let has_grab = flags & 0x0000_0080 != 0 || flags & 0x0000_0040 != 0;
if has_flip && has_grab && frame.flags_2488 & 0x0200_0000 == 0 {
let threshold = settings
.flip_start_collision_time_vs_normal_y
.evaluate(reckoning.landing_normal_1152[1]);
if state.collision_time_196 > threshold {
runtime.begin_reckoning_body_flip((flags >> 7) & 1 != 0);
state.body_flipping_211 = true;
}
}
}
if mode.perfect_body_flips_28 {
return state.body_flip_target_speed_204;
}
let flags = frame.flags_2468;
if flags & 0x0000_0020 != 0
|| flags & 0x0000_0010 != 0
|| state.time_in_state_180 < settings.body_flip_min_grab_time_fraction_456
{
state.body_flip_target_speed_204
} else {
0.0
}
}
/// `PhysState_KnownAir::CalculateBodySpinSpeed`, TU3 `0x82D360B0`.
pub fn calculate_body_spin_speed(
state: &KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
mode: &KnownAirModeSettings,
reckoning: &KnownAirReckoningFields,
runtime: &mut impl KnownAirRuntime,
) -> f32 {
let scaled_input = frame.body_spin_input_2640 * settings.max_spin_speed_428;
let manual_spin = scaled_input * DEGREES_TO_RADIANS;
let mut result = if frame.flags_2472 & 0x1000_0000 != 0 {
manual_spin
} else {
0.0
};
// Native tests the unmasked manual value, not `result`.
if manual_spin == 0.0 && state.landing_heading_valid_209 && frame.flags_2484 & 0x0000_8000 == 0
{
let mut heading_axis = reckoning.heading_axis_1200;
if ((frame.flags_2468 >> 20) & 1 != 0) != state.start_flipped_210 {
heading_axis = flip_sign_bits(heading_axis);
}
let mut unwrapped_angle = 0.0;
if runtime.dot3(reckoning.landing_normal_1152, state.landing_normal_64) > DIRECTION_EPSILON
{
unwrapped_angle = runtime.signed_angle_about_axis(
heading_axis,
state.landing_heading_80,
reckoning.landing_normal_1152,
);
if frame.flags_2468 & 0x0010_0000 != 0 {
unwrapped_angle += PI;
}
}
let mut corrected_angle = runtime.wrap_angle_8258db98(unwrapped_angle);
let heading_degrees = corrected_angle * RADIANS_TO_DEGREES;
let max_adjust = settings
.max_heading_adjust_vs_up_y_160
.evaluate(frame.skater_up_544[1]);
if heading_degrees.abs() > max_adjust {
let wrapped_pi = runtime.wrap_angle_8258db98(PI);
corrected_angle = runtime.wrap_angle_8258db98(wrapped_pi + corrected_angle);
}
let mut current_speed = reckoning.body_spin_speed_1572;
let remaining = state.collision_time_196 - state.time_in_state_180;
let alignment_time = select(remaining - ALIGNMENT_DELAY, remaining, ALIGNMENT_DELAY);
let reciprocal_time = 1.0 / alignment_time;
let mut target = (reciprocal_time * corrected_angle) * AUTO_SPIN_SCALE;
if current_speed.abs() < DIRECTION_EPSILON {
current_speed = 0.0;
}
if current_speed * target < 0.0 {
let alternate = runtime.wrap_angle_8258db98(unwrapped_angle + PI);
if FORTY_FIVE_DEGREES * DEGREES_TO_RADIANS > alternate.abs()
&& (reciprocal_time * alternate) * current_speed > 0.0
{
target = reciprocal_time * alternate;
}
}
result = native_clamp(
target,
-mode.body_spin_speed_limit_56,
mode.body_spin_speed_limit_56,
);
if result.abs() < settings.min_auto_body_speed_424 {
result *= HALF;
}
if unwrapped_angle.abs() < settings.min_auto_body_speed_424 {
result = (unwrapped_angle / frame.delta_time_2604) * HALF;
}
if result * current_speed < 0.0 {
result *= REVERSE_SCALE;
} else if current_speed.abs() > result.abs() {
result *= DECELERATION_SCALE;
}
}
result
}
fn flip_sign_bits(vector: Vector4) -> Vector4 {
vector.map(|lane| f32::from_bits(lane.to_bits() ^ 0x8000_0000))
}
fn native_clamp(value: f32, lower: f32, upper: f32) -> f32 {
let value = select(lower - value, lower, value);
select(upper - value, value, upper)
}
+193
View File
@@ -0,0 +1,193 @@
//! Persistent state and direct inputs for TU3 `PhysState_KnownAir`.
use crate::point_graph::PointGraph;
pub type Vector4 = [f32; 4];
/// The 64-byte trajectory copied by `FillPhysOut` at `0x82D36A78`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirTrajectory {
pub position: Vector4,
pub velocity: Vector4,
pub acceleration: Vector4,
pub scalar_48: f32,
pub word_52: u32,
pub word_56: u32,
pub word_60: u32,
}
/// Winning trajectory result at selector+1680. The collision frame lies at
/// result+128, outside the copied 64-byte trajectory at result+144.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirPrediction {
pub trajectory: KnownAirTrajectory,
/// Winning PredictionResults+48, distinct from request horizon at +192.
pub collision_time_48: f32,
pub collision_frame_128: i32,
}
/// Fields owned by the KnownAir object at native offsets +64..+216.
/// Construction remains caller-owned because Enter does not initialize every
/// field before `InitTrajectoryInfo` and the selector helpers run.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirState {
pub landing_normal_64: Vector4,
pub landing_heading_80: Vector4,
pub trajectory_apex_96: Vector4,
pub collision_position_112: Vector4,
pub selector_vector_128: Vector4,
pub trajectory_follow_offset_144: Vector4,
pub target_com_position_160: Vector4,
pub collision_normal_speed_176: f32,
pub time_in_state_180: f32,
pub start_y_184: f32,
pub max_y_188: f32,
pub com_max_y_192: f32,
pub collision_time_196: f32,
pub time_to_apex_200: f32,
pub body_flip_target_speed_204: f32,
pub reached_apex_208: bool,
pub landing_heading_valid_209: bool,
pub start_flipped_210: bool,
pub body_flipping_211: bool,
pub grind_air_adjust_activated_212: bool,
pub targeting_grind_213: bool,
pub trajectory_index_216: i32,
}
/// Direct ProcessedPhysIn fields consumed by KnownAir.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirFrame {
pub start_flip_reference_96: Vector4,
pub alternate_head_target_112: Vector4,
pub velocity_400: Vector4,
pub ground_normal_464: Vector4,
pub start_height_484: f32,
pub skater_up_544: Vector4,
pub flags_2468: u32,
pub flags_2472: u32,
pub flags_2476: u32,
pub flags_2480: u32,
pub flags_2484: u32,
pub flags_2488: u32,
pub next_physics_state_2500: i32,
pub delta_time_2604: f32,
pub forward_speed_2612: f32,
pub body_spin_input_2640: f32,
pub selector_landing_normal_2656: Vector4,
}
/// Cached `physics_airstates/default.xml` values used by this state. Values
/// must come from the stock collection adapter; no retail defaults live here.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirSettings {
/// Settings +160; x at +176, y at +208.
pub max_heading_adjust_vs_up_y_160: PointGraph<8>,
/// Settings +240; x at +256, y at +288.
pub landing_speed_scalar_vs_ground_normal_y_240: PointGraph<8>,
/// Attribute hash `AA79C93673533C5B`; its recovered name remains unknown.
pub flip_start_collision_time_vs_normal_y: PointGraph<8>,
pub trajectory_error_blend_away_time_384: f32,
pub min_target_heading_velocity_420: f32,
pub min_auto_body_speed_424: f32,
pub max_spin_speed_428: f32,
pub frames_for_grind_air_assist_436: f32,
pub body_flip_min_grab_time_fraction_456: f32,
/// `physics_reckoning/FlipScalar`, hash `978CE73B0EFE76C8`.
pub flip_scalar: f32,
}
/// Fields read through ProcessedPhysIn+2548 and its selected physics mode.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirModeSettings {
pub upside_down_falling_wipeout_enabled_2: bool,
pub perfect_body_flips_28: bool,
pub body_spin_speed_limit_56: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirWipeoutSettings {
pub air_falling_min_up_y_260: f32,
pub air_falling_max_angle_264: f32,
}
/// Direct Reckoning fields read or cleared by KnownAir.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirReckoningFields {
pub landing_normal_1152: Vector4,
pub heading_axis_1200: Vector4,
pub body_spin_angle_1568: f32,
pub body_spin_speed_1572: f32,
}
/// Direct writes to the wipeout request object at +35, +116, and +200.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirWipeoutRequest {
pub requested_35: bool,
pub scalar_116: f32,
pub counter_200: u32,
}
/// Six skeleton bodies disabled, in this exact native order, while the grind
/// air adjustment owns the feet.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GrindFootBody {
Body3152,
Body3156,
Body3160,
Body3168,
Body3172,
Body3176,
}
/// Trajectory packet passed to the three recovered footplant updates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirFootplantInput {
pub trajectory: KnownAirTrajectory,
pub remaining_collision_time: f32,
pub collision_position: Vector4,
pub landing_normal: Vector4,
}
/// Native transform at Reckoning+816, interpreted only by the required math
/// adapter because Xenon lane order remains part of that contract.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AffineTransform {
pub vectors: [Vector4; 4],
}
/// Stable inputs/outputs around the unresolved VMX geometry in `0x82D35998`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RestoreVelocityGeometry {
pub tangential_velocity: Vector4,
pub landing_speed_curve_input: f32,
/// Source clamped to [0, 1] by the two native `fsel` operations.
pub landing_speed_blend_source: f32,
}
/// Fields written by `FillPhysOut` (`0x82D36880`). The two conditional fields
/// retain their previous value when flags+2468 bit 3 is clear.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KnownAirOutput {
pub trajectory_apex_0: Vector4,
pub collision_position_16: Vector4,
pub landing_normal_32: Vector4,
pub selector_vector_48: Vector4,
pub trajectory_position_64: Vector4,
pub landing_heading_80: Vector4,
pub selector_com_position_96: Vector4,
pub landing_normal_copy_144: Vector4,
pub locked_trajectory_velocity_160: Vector4,
pub time_in_state_176: f32,
pub collision_time_180: f32,
pub time_until_collision_184: f32,
pub collision_normal_speed_188: f32,
pub time_to_apex_196: f32,
pub jump_height_200: f32,
pub trajectory_index_220: i32,
pub selected_trajectory_240: KnownAirTrajectory,
pub trajectory_plane_samples_336: [f32; 25],
pub reached_apex_436: bool,
pub known_air_valid_437: bool,
pub locked_trajectory_velocity_valid_452: bool,
}
@@ -0,0 +1,34 @@
//! Footplant branch embedded in TU3 KnownAir update.
use super::{
data::{KnownAirFootplantInput, KnownAirFrame, KnownAirState},
runtime::KnownAirRuntime,
};
const MIN_FOOTPLANT_TIME: f32 = f32::from_bits(0x3DCC_CCCD); // 0x820641A8.
/// `0x82D36728`, preserving the reset gate and the three update-call order.
pub(crate) fn update_footplant(
state: &KnownAirState,
frame: &KnownAirFrame,
runtime: &mut impl KnownAirRuntime,
) {
if frame.flags_2480 & 0x0400_0000 == 0
|| frame.flags_2476 & 0x0400_0000 != 0
|| state.time_in_state_180 <= MIN_FOOTPLANT_TIME
{
runtime.reset_footplants();
return;
}
let mut input = KnownAirFootplantInput {
trajectory: runtime.selector_trajectory_2704(),
remaining_collision_time: state.collision_time_196 - state.time_in_state_180,
collision_position: state.collision_position_112,
landing_normal: state.landing_normal_64,
};
runtime.shift_footplant_trajectory_to_index(&mut input.trajectory, state.trajectory_index_216);
runtime.update_footplant_prediction(&input);
runtime.update_footplant_lock(&input);
runtime.update_footplant_pose(&input);
}
@@ -0,0 +1,73 @@
//! TU3 KnownAir entry and exit lifecycle.
use super::{
data::{
KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields, KnownAirSettings,
KnownAirState,
},
runtime::KnownAirRuntime,
trajectory::{init_trajectory_info, restore_velocity},
};
const ZERO_VECTOR: [f32; 4] = [0.0; 4]; // 0x830BD350.
/// `PhysState_KnownAir::Enter`, TU3 `0x82D352D0`.
pub fn enter(
state: &mut KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
mode: &KnownAirModeSettings,
runtime: &mut impl KnownAirRuntime,
) {
runtime.enable_board_angular_drive_only();
runtime.set_air_collision_update_enabled(true);
runtime.set_skeleton_collision_state(7);
runtime.set_skeleton_physics_truck_tilt_enabled(false);
runtime.set_skeleton_inverse_kinematics_enabled(true);
state.body_flipping_211 = false;
state.grind_air_adjust_activated_212 = false;
state.targeting_grind_213 = false;
runtime.reset_reckoning_flipping();
runtime.set_footplant_flag_240(false);
runtime.reset_footplants();
state.reached_apex_208 = false;
state.time_in_state_180 = 0.0;
state.start_y_184 = frame.start_height_484;
state.max_y_188 = runtime.board_transform_height();
state.com_max_y_192 = runtime.skeleton_com_position()[1];
init_trajectory_info(state, frame, settings, mode, runtime);
if runtime.selector_targeting_grind() {
runtime.start_grind_air_adjust_from_selector();
state.targeting_grind_213 = true;
}
let skeleton_com = runtime.skeleton_com_position();
let (index, offset) = runtime.selector_closest_trajectory_point(skeleton_com, ZERO_VECTOR);
state.trajectory_index_216 = index;
state.trajectory_follow_offset_144 = offset;
state.start_flipped_210 = 0.0 > runtime.dot3(frame.velocity_400, frame.start_flip_reference_96);
}
/// `PhysState_KnownAir::Exit`, TU3 `0x82D35920`.
pub fn exit(
state: &mut KnownAirState,
frame: &mut KnownAirFrame,
settings: &KnownAirSettings,
reckoning: &mut KnownAirReckoningFields,
runtime: &mut impl KnownAirRuntime,
) {
runtime.reset_reckoning_flipping();
if frame.next_physics_state_2500 == 100 {
restore_velocity(state, frame, settings, runtime);
}
runtime.reset_trajectory_selector();
reckoning.body_spin_speed_1572 = 0.0;
reckoning.body_spin_angle_1568 = 0.0;
runtime.set_grind_air_adjust_started(false);
runtime.set_grind_air_adjust_activated(false);
}
+33
View File
@@ -0,0 +1,33 @@
//! Readable TU3 `PhysState_KnownAir` (state 201).
//!
//! The routines in this module preserve the observed Skate 3 call, branch,
//! field, and constant order. Engine-owned objects and Xenon-specific vector
//! operations are mandatory adapter contracts; this module supplies no
//! guessed fallbacks.
mod body;
mod data;
mod footplant;
mod lifecycle;
mod output;
mod post_physics;
mod runtime;
mod trajectory;
mod update;
pub use body::{calculate_body_flip_speed, calculate_body_spin_speed};
pub use data::{
AffineTransform, GrindFootBody, KnownAirFootplantInput, KnownAirFrame, KnownAirModeSettings,
KnownAirOutput, KnownAirPrediction, KnownAirReckoningFields, KnownAirSettings, KnownAirState,
KnownAirTrajectory, KnownAirWipeoutRequest, KnownAirWipeoutSettings, RestoreVelocityGeometry,
Vector4,
};
pub use lifecycle::{enter, exit};
pub use output::fill_physics_output;
pub use post_physics::{check_upside_down_falling_wipeout, update_post_physics};
pub use runtime::{KnownAirMath, KnownAirRuntime};
pub use trajectory::{init_trajectory_info, restore_velocity, update_trajectory_follow};
pub use update::update;
#[cfg(test)]
mod tests;
+62
View File
@@ -0,0 +1,62 @@
//! TU3 KnownAir PhysOut publication.
use super::{
data::{KnownAirFrame, KnownAirOutput, KnownAirState},
runtime::KnownAirRuntime,
trajectory::{add, sub, trajectory_position, trajectory_velocity},
};
const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 0x820849C8, 1/60.
const MAX_FINITE: f32 = f32::from_bits(0x7F7F_FFFF); // 0x8206D108.
/// `PhysState_KnownAir::FillPhysOut`, TU3 `0x82D36880`.
pub fn fill_physics_output(
state: &KnownAirState,
frame: &KnownAirFrame,
output: &mut KnownAirOutput,
runtime: &mut impl KnownAirRuntime,
) {
let prediction = runtime.selected_prediction();
if frame.flags_2468 & 0x0000_0008 != 0 {
output.locked_trajectory_velocity_valid_452 = true;
let time = state.trajectory_index_216 as f32 * FIXED_STEP;
output.locked_trajectory_velocity_160 = trajectory_velocity(&prediction.trajectory, time);
}
output.reached_apex_436 = state.reached_apex_208;
output.jump_height_200 = state.max_y_188 - state.start_y_184;
output.trajectory_apex_0 = state.trajectory_apex_96;
output.time_to_apex_196 = state.time_to_apex_200;
output.collision_position_16 = state.collision_position_112;
output.landing_normal_32 = state.landing_normal_64;
output.landing_normal_copy_144 = state.landing_normal_64;
output.time_until_collision_184 = if frame.flags_2468 & 0x0000_0008 != 0 {
MAX_FINITE
} else {
state.collision_time_196 - state.time_in_state_180
};
output.time_in_state_176 = state.time_in_state_180;
output.selector_vector_48 = state.selector_vector_128;
let output_index = state.trajectory_index_216.max(1);
output.trajectory_position_64 =
trajectory_position(&prediction.trajectory, output_index as f32 * FIXED_STEP);
output.landing_heading_80 = state.landing_heading_80;
output.collision_normal_speed_188 = state.collision_normal_speed_176;
output.known_air_valid_437 = true;
output.selected_trajectory_240 = prediction.trajectory;
output.trajectory_index_220 = state.trajectory_index_216;
let selector_com = runtime.selector_com_position_2784();
output.selector_com_position_96 = selector_com;
output.collision_time_180 = state.collision_time_196;
let com_transform = runtime.reckoning_com_transform_816();
let local_com = runtime.transform_point_82d36880(com_transform, selector_com);
let first_sample = state.trajectory_index_216.wrapping_sub(1).max(0);
for (sample_offset, destination) in output.trajectory_plane_samples_336.iter_mut().enumerate() {
let sample_index = first_sample.wrapping_add(sample_offset as i32);
let sample = trajectory_position(&prediction.trajectory, sample_index as f32 * FIXED_STEP);
let relative = sub(add(sample, local_com), state.collision_position_112);
*destination = runtime.dot3(relative, state.landing_normal_64);
}
}
@@ -0,0 +1,55 @@
//! TU3 KnownAir post-physics and upside-down wipeout handling.
use super::{
data::{
KnownAirFrame, KnownAirModeSettings, KnownAirState, KnownAirWipeoutRequest,
KnownAirWipeoutSettings,
},
runtime::KnownAirRuntime,
};
/// `PhysState_KnownAir::UpdatePostPhysics`, TU3 `0x82D36590`.
pub fn update_post_physics(
state: &mut KnownAirState,
frame: &KnownAirFrame,
mode: &KnownAirModeSettings,
settings: &KnownAirWipeoutSettings,
request: &mut KnownAirWipeoutRequest,
runtime: &mut impl KnownAirRuntime,
) {
if !state.reached_apex_208 {
let velocity = runtime.board_body_velocity();
if velocity[1] < 0.0 {
state.reached_apex_208 = true;
}
}
runtime.check_for_air_wipeout(true);
check_upside_down_falling_wipeout(state, frame, mode, settings, request, runtime);
}
/// `PhysState_KnownAir::CheckUpsideDownFallingWipeout`, TU3 `0x82D36650`.
pub fn check_upside_down_falling_wipeout(
state: &KnownAirState,
frame: &KnownAirFrame,
mode: &KnownAirModeSettings,
settings: &KnownAirWipeoutSettings,
request: &mut KnownAirWipeoutRequest,
runtime: &mut impl KnownAirRuntime,
) {
if !mode.upside_down_falling_wipeout_enabled_2 {
return;
}
if frame.flags_2468 & 0x0000_0020 != 0 {
return;
}
if !(frame.skater_up_544[1] < settings.air_falling_min_up_y_260) {
return;
}
if runtime.dot3(state.landing_normal_64, frame.skater_up_544)
< settings.air_falling_max_angle_264
{
request.requested_35 = true;
request.scalar_116 = 0.0;
request.counter_200 = request.counter_200.wrapping_add(1);
}
}
@@ -0,0 +1,98 @@
//! Mandatory engine and Xenon-math boundaries for TU3 KnownAir.
use super::data::{
AffineTransform, GrindFootBody, KnownAirFootplantInput, KnownAirPrediction, KnownAirTrajectory,
RestoreVelocityGeometry, Vector4,
};
/// Xenon operations whose exact estimate/refinement, lane, or angle behavior
/// is observable in the recovered routines. There is deliberately no default
/// implementation or scalar substitute.
pub trait KnownAirMath {
/// `vmsum3fp128` including its native rounding and lane behavior.
fn dot3(&mut self, left: Vector4, right: Vector4) -> f32;
/// The `vrefp` reciprocal refinement sequence used for finite differences.
fn divide_vector_by_scalar(&mut self, vector: Vector4, scalar: f32) -> Vector4;
/// The two `vrsqrtefp128` refinements, zero selection, and returned length.
fn normalize_safe_with_length(&mut self, vector: Vector4) -> (Vector4, f32);
/// `0x8286CD88`, including its native orientation and degeneracy behavior.
fn signed_angle_about_axis(&mut self, from: Vector4, to: Vector4, axis: Vector4) -> f32;
/// `0x8258DB98` exactly; callers retain every observed invocation.
fn wrap_angle_8258db98(&mut self, angle: f32) -> f32;
/// VMX geometry at `0x82D35A0C..0x82D35B78`. The surrounding trajectory
/// evaluation, curve evaluation, blend, board write, and speed publication
/// remain explicit in readable Rust.
fn restore_velocity_geometry_82d35998(
&mut self,
trajectory_velocity: Vector4,
ground_normal: Vector4,
) -> RestoreVelocityGeometry;
/// Affine point transform at `0x82D36AE4..0x82D36B30`.
fn transform_point_82d36880(&mut self, transform: AffineTransform, point: Vector4) -> Vector4;
}
/// Every external call and direct engine-owned object mutation performed by
/// the recovered KnownAir routines. Implementers must bind all methods.
pub trait KnownAirRuntime: KnownAirMath {
fn enable_board_angular_drive_only(&mut self);
fn set_air_collision_update_enabled(&mut self, enabled: bool);
fn set_skeleton_collision_state(&mut self, state: u32);
fn set_skeleton_physics_truck_tilt_enabled(&mut self, enabled: bool);
fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool);
fn reset_reckoning_flipping(&mut self);
fn set_footplant_flag_240(&mut self, value: bool);
fn reset_footplants(&mut self);
fn board_transform_height(&mut self) -> f32;
fn skeleton_com_position(&mut self) -> Vector4;
fn selector_targeting_grind(&self) -> bool;
fn start_grind_air_adjust_from_selector(&mut self);
fn selector_closest_trajectory_point(
&mut self,
skeleton_com: Vector4,
zero: Vector4,
) -> (i32, Vector4);
fn selected_prediction(&self) -> KnownAirPrediction;
/// Selector+2704, distinct from the winning result trajectory at +1680.
fn selector_trajectory_2704(&self) -> KnownAirTrajectory;
fn selector_contact_position(&mut self) -> Vector4;
fn selector_vector_2848(&self) -> Vector4;
fn selector_com_position_2784(&self) -> Vector4;
fn highest_trajectory_position(&mut self, trajectory: &KnownAirTrajectory) -> (Vector4, f32);
fn selector_has_just_changed(&self) -> bool;
fn reset_trajectory_selector(&mut self);
fn begin_reckoning_body_flip(&mut self, side: bool);
fn update_air_collision(&mut self);
fn grind_air_adjust_started(&self) -> bool;
fn set_grind_air_adjust_started(&mut self, value: bool);
fn set_grind_air_adjust_activated(&mut self, value: bool);
fn grind_air_adjust_adjusting(&self) -> bool;
fn disable_grind_foot_collision(&mut self, body: GrindFootBody);
fn update_reckoning_air_states(
&mut self,
landing_normal: Vector4,
alignment_weight: f32,
body_spin_speed: f32,
body_flip_speed: f32,
);
fn update_known_air_skeleton(&mut self, target_com_position: Vector4);
fn set_skeleton_add_skateboard_error(&mut self, value: bool);
fn set_head_tracking_target(&mut self, target: Vector4, valid: bool);
fn update_board_steering_tilt(&mut self, tilt: f32);
fn shift_footplant_trajectory_to_index(
&mut self,
trajectory: &mut KnownAirTrajectory,
index: i32,
);
fn update_footplant_prediction(&mut self, input: &KnownAirFootplantInput);
fn update_footplant_lock(&mut self, input: &KnownAirFootplantInput);
fn update_footplant_pose(&mut self, input: &KnownAirFootplantInput);
fn board_body_velocity(&mut self) -> Vector4;
fn set_board_velocity(&mut self, velocity: Vector4);
fn board_forward_axis(&mut self) -> Vector4;
fn check_for_air_wipeout(&mut self, use_com: bool);
fn reckoning_com_transform_816(&self) -> AffineTransform;
}
@@ -0,0 +1,376 @@
use crate::point_graph::PointGraph;
use super::super::*;
pub fn flat_graph(value: f32) -> PointGraph<8> {
PointGraph {
x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
y: [value; 8],
}
}
pub fn trajectory() -> KnownAirTrajectory {
KnownAirTrajectory {
position: [0.0; 4],
velocity: [0.0; 4],
acceleration: [0.0; 4],
scalar_48: 1.0,
word_52: 0,
word_56: 0,
word_60: 0,
}
}
pub fn state() -> KnownAirState {
KnownAirState {
landing_normal_64: [0.0, 1.0, 0.0, 0.0],
landing_heading_80: [1.0, 0.0, 0.0, 0.0],
trajectory_apex_96: [0.0; 4],
collision_position_112: [0.0; 4],
selector_vector_128: [0.0; 4],
trajectory_follow_offset_144: [0.0; 4],
target_com_position_160: [0.0; 4],
collision_normal_speed_176: 0.0,
time_in_state_180: 0.0,
start_y_184: 0.0,
max_y_188: 0.0,
com_max_y_192: 0.0,
collision_time_196: 1.0,
time_to_apex_200: 0.0,
body_flip_target_speed_204: 0.0,
reached_apex_208: false,
landing_heading_valid_209: false,
start_flipped_210: false,
body_flipping_211: false,
grind_air_adjust_activated_212: false,
targeting_grind_213: false,
trajectory_index_216: 0,
}
}
pub fn frame() -> KnownAirFrame {
KnownAirFrame {
start_flip_reference_96: [0.0; 4],
alternate_head_target_112: [0.0; 4],
velocity_400: [0.0; 4],
ground_normal_464: [0.0, 1.0, 0.0, 0.0],
start_height_484: 0.0,
skater_up_544: [0.0, 1.0, 0.0, 0.0],
flags_2468: 0,
flags_2472: 0,
flags_2476: 0,
flags_2480: 0,
flags_2484: 0,
flags_2488: 0,
next_physics_state_2500: 0,
delta_time_2604: 1.0 / 60.0,
forward_speed_2612: 0.0,
body_spin_input_2640: 0.0,
selector_landing_normal_2656: [0.0, 1.0, 0.0, 0.0],
}
}
pub fn settings() -> KnownAirSettings {
KnownAirSettings {
max_heading_adjust_vs_up_y_160: flat_graph(180.0),
landing_speed_scalar_vs_ground_normal_y_240: flat_graph(1.0),
flip_start_collision_time_vs_normal_y: flat_graph(10.0),
trajectory_error_blend_away_time_384: 1.0,
min_target_heading_velocity_420: 0.1,
min_auto_body_speed_424: 0.01,
max_spin_speed_428: 360.0,
frames_for_grind_air_assist_436: 4.0,
body_flip_min_grab_time_fraction_456: 0.25,
flip_scalar: 1.0,
}
}
pub fn mode() -> KnownAirModeSettings {
KnownAirModeSettings {
upside_down_falling_wipeout_enabled_2: true,
perfect_body_flips_28: false,
body_spin_speed_limit_56: 20.0,
}
}
pub fn reckoning() -> KnownAirReckoningFields {
KnownAirReckoningFields {
landing_normal_1152: [0.0, 1.0, 0.0, 0.0],
heading_axis_1200: [1.0, 0.0, 0.0, 0.0],
body_spin_angle_1568: 0.0,
body_spin_speed_1572: 0.0,
}
}
pub fn output() -> KnownAirOutput {
KnownAirOutput {
trajectory_apex_0: [0.0; 4],
collision_position_16: [0.0; 4],
landing_normal_32: [0.0; 4],
selector_vector_48: [0.0; 4],
trajectory_position_64: [0.0; 4],
landing_heading_80: [0.0; 4],
selector_com_position_96: [0.0; 4],
landing_normal_copy_144: [0.0; 4],
locked_trajectory_velocity_160: [0.0; 4],
time_in_state_176: 0.0,
collision_time_180: 0.0,
time_until_collision_184: 0.0,
collision_normal_speed_188: 0.0,
time_to_apex_196: 0.0,
jump_height_200: 0.0,
trajectory_index_220: 0,
selected_trajectory_240: trajectory(),
trajectory_plane_samples_336: [0.0; 25],
reached_apex_436: false,
known_air_valid_437: false,
locked_trajectory_velocity_valid_452: false,
}
}
pub struct MockRuntime {
pub calls: Vec<String>,
pub prediction: KnownAirPrediction,
pub selector_trajectory: KnownAirTrajectory,
pub contact: Vector4,
pub selector_vector: Vector4,
pub selector_com: Vector4,
pub highest: (Vector4, f32),
pub board_y: f32,
pub com: Vector4,
pub targeting_grind: bool,
pub closest: (i32, Vector4),
pub grind_started: bool,
pub grind_adjusting: bool,
pub board_velocity: Vector4,
pub written_board_velocity: Vector4,
pub forward: Vector4,
pub signed_angle: f32,
pub restore_geometry: RestoreVelocityGeometry,
}
impl MockRuntime {
pub fn new() -> Self {
Self {
calls: Vec::new(),
prediction: KnownAirPrediction {
trajectory: trajectory(),
collision_time_48: 1.0,
collision_frame_128: 2,
},
selector_trajectory: trajectory(),
contact: [0.0; 4],
selector_vector: [0.0; 4],
selector_com: [0.0; 4],
highest: ([0.0; 4], 0.0),
board_y: 0.0,
com: [0.0; 4],
targeting_grind: false,
closest: (0, [0.0; 4]),
grind_started: false,
grind_adjusting: false,
board_velocity: [0.0; 4],
written_board_velocity: [0.0; 4],
forward: [1.0, 0.0, 0.0, 0.0],
signed_angle: 0.0,
restore_geometry: RestoreVelocityGeometry {
tangential_velocity: [0.0; 4],
landing_speed_curve_input: 0.0,
landing_speed_blend_source: 0.0,
},
}
}
fn call(&mut self, value: impl Into<String>) {
self.calls.push(value.into());
}
}
impl KnownAirMath for MockRuntime {
fn dot3(&mut self, left: Vector4, right: Vector4) -> f32 {
left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
}
fn divide_vector_by_scalar(&mut self, vector: Vector4, scalar: f32) -> Vector4 {
vector.map(|value| value / scalar)
}
fn normalize_safe_with_length(&mut self, vector: Vector4) -> (Vector4, f32) {
let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
if length == 0.0 {
([0.0; 4], 0.0)
} else {
(vector.map(|value| value / length), length)
}
}
fn signed_angle_about_axis(&mut self, _: Vector4, _: Vector4, _: Vector4) -> f32 {
self.call("math.signed_angle");
self.signed_angle
}
fn wrap_angle_8258db98(&mut self, angle: f32) -> f32 {
self.call("math.wrap");
angle
}
fn restore_velocity_geometry_82d35998(
&mut self,
_: Vector4,
_: Vector4,
) -> RestoreVelocityGeometry {
self.call("math.restore_geometry");
self.restore_geometry
}
fn transform_point_82d36880(&mut self, _: AffineTransform, point: Vector4) -> Vector4 {
point
}
}
impl KnownAirRuntime for MockRuntime {
fn enable_board_angular_drive_only(&mut self) {
self.call("board.angular_only");
}
fn set_air_collision_update_enabled(&mut self, value: bool) {
self.call(format!("collision.enabled:{value}"));
}
fn set_skeleton_collision_state(&mut self, value: u32) {
self.call(format!("collision.state:{value}"));
}
fn set_skeleton_physics_truck_tilt_enabled(&mut self, value: bool) {
self.call(format!("skeleton.truck_tilt:{value}"));
}
fn set_skeleton_inverse_kinematics_enabled(&mut self, value: bool) {
self.call(format!("skeleton.ik:{value}"));
}
fn reset_reckoning_flipping(&mut self) {
self.call("reckoning.reset_flip");
}
fn set_footplant_flag_240(&mut self, value: bool) {
self.call(format!("footplant.flag:{value}"));
}
fn reset_footplants(&mut self) {
self.call("footplant.reset");
}
fn board_transform_height(&mut self) -> f32 {
self.call("board.height");
self.board_y
}
fn skeleton_com_position(&mut self) -> Vector4 {
self.call("skeleton.com");
self.com
}
fn selector_targeting_grind(&self) -> bool {
self.targeting_grind
}
fn start_grind_air_adjust_from_selector(&mut self) {
self.call("grind.start");
}
fn selector_closest_trajectory_point(&mut self, _: Vector4, _: Vector4) -> (i32, Vector4) {
self.call("selector.closest");
self.closest
}
fn selected_prediction(&self) -> KnownAirPrediction {
self.prediction
}
fn selector_trajectory_2704(&self) -> KnownAirTrajectory {
self.selector_trajectory
}
fn selector_contact_position(&mut self) -> Vector4 {
self.call("selector.contact");
self.contact
}
fn selector_vector_2848(&self) -> Vector4 {
self.selector_vector
}
fn selector_com_position_2784(&self) -> Vector4 {
self.selector_com
}
fn highest_trajectory_position(&mut self, _: &KnownAirTrajectory) -> (Vector4, f32) {
self.call("trajectory.highest");
self.highest
}
fn selector_has_just_changed(&self) -> bool {
false
}
fn reset_trajectory_selector(&mut self) {
self.call("selector.reset");
}
fn begin_reckoning_body_flip(&mut self, side: bool) {
self.call(format!("reckoning.begin_flip:{side}"));
}
fn update_air_collision(&mut self) {
self.call("collision.update");
}
fn grind_air_adjust_started(&self) -> bool {
self.grind_started
}
fn set_grind_air_adjust_started(&mut self, value: bool) {
self.call(format!("grind.started:{value}"));
}
fn set_grind_air_adjust_activated(&mut self, value: bool) {
self.call(format!("grind.activated:{value}"));
}
fn grind_air_adjust_adjusting(&self) -> bool {
self.grind_adjusting
}
fn disable_grind_foot_collision(&mut self, body: GrindFootBody) {
let name = match body {
GrindFootBody::Body3152 => "3152",
GrindFootBody::Body3156 => "3156",
GrindFootBody::Body3160 => "3160",
GrindFootBody::Body3168 => "3168",
GrindFootBody::Body3172 => "3172",
GrindFootBody::Body3176 => "3176",
};
self.call(format!("grind.disable:{name}"));
}
fn update_reckoning_air_states(&mut self, _: Vector4, _: f32, _: f32, _: f32) {
self.call("reckoning.update_air");
}
fn update_known_air_skeleton(&mut self, _: Vector4) {
self.call("skeleton.update_known_air");
}
fn set_skeleton_add_skateboard_error(&mut self, value: bool) {
self.call(format!("skeleton.board_error:{value}"));
}
fn set_head_tracking_target(&mut self, _: Vector4, valid: bool) {
self.call(format!("head.target:{valid}"));
}
fn update_board_steering_tilt(&mut self, _: f32) {
self.call("board.steering_zero");
}
fn shift_footplant_trajectory_to_index(&mut self, _: &mut KnownAirTrajectory, _: i32) {
self.call("footplant.shift");
}
fn update_footplant_prediction(&mut self, _: &KnownAirFootplantInput) {
self.call("footplant.prediction");
}
fn update_footplant_lock(&mut self, _: &KnownAirFootplantInput) {
self.call("footplant.lock");
}
fn update_footplant_pose(&mut self, _: &KnownAirFootplantInput) {
self.call("footplant.pose");
}
fn board_body_velocity(&mut self) -> Vector4 {
self.call("board.velocity");
self.board_velocity
}
fn set_board_velocity(&mut self, value: Vector4) {
self.call("board.set_velocity");
self.written_board_velocity = value;
}
fn board_forward_axis(&mut self) -> Vector4 {
self.call("board.forward");
self.forward
}
fn check_for_air_wipeout(&mut self, value: bool) {
self.call(format!("wipeout.check:{value}"));
}
fn reckoning_com_transform_816(&self) -> AffineTransform {
AffineTransform {
vectors: [[0.0; 4]; 4],
}
}
}
@@ -0,0 +1,404 @@
use super::*;
mod mock;
use mock::{MockRuntime, frame, mode, output, reckoning, settings, state};
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.000_01,
"{actual} != {expected}"
);
}
#[test]
fn init_trajectory_info_builds_stock_landing_values() {
let mut state = state();
let mut frame = frame();
frame.selector_landing_normal_2656 = [0.0, 1.0, 0.0, 0.0];
let mut settings = settings();
settings.flip_scalar = 0.5;
settings.min_target_heading_velocity_420 = 0.5;
let mode = mode();
let mut runtime = MockRuntime::new();
runtime.prediction.trajectory.velocity = [3.0, 4.0, 0.0, 0.0];
runtime.prediction.trajectory.scalar_48 = 9.0; // Independent query horizon.
runtime.prediction.collision_time_48 = 2.0;
runtime.prediction.collision_frame_128 = 3;
runtime.contact = [9.0, 8.0, 7.0, 0.0];
runtime.selector_vector = [6.0, 5.0, 4.0, 0.0];
runtime.highest = ([1.0, 12.0, 3.0, 0.0], 0.75);
init_trajectory_info(&mut state, &frame, &settings, &mode, &mut runtime);
assert_eq!(state.trajectory_apex_96, runtime.highest.0);
assert_eq!(state.time_to_apex_200, 0.75);
assert_eq!(state.collision_position_112, runtime.contact);
assert_eq!(state.selector_vector_128, runtime.selector_vector);
assert_close(state.collision_normal_speed_176, 4.0);
assert_eq!(state.landing_heading_80, [1.0, 0.0, 0.0, 0.0]);
assert!(state.landing_heading_valid_209);
assert_close(
state.body_flip_target_speed_204,
-core::f32::consts::FRAC_PI_2,
);
}
#[test]
fn init_negative_collision_clears_speed_and_heading_validity() {
let mut state = state();
state.collision_normal_speed_176 = 99.0;
state.landing_heading_valid_209 = true;
let frame = frame();
let settings = settings();
let mode = mode();
let mut runtime = MockRuntime::new();
runtime.prediction.collision_time_48 = -1.0;
init_trajectory_info(&mut state, &frame, &settings, &mode, &mut runtime);
assert_eq!(state.collision_normal_speed_176, 0.0);
assert_eq!(state.landing_heading_80, [0.0; 4]);
assert!(!state.landing_heading_valid_209);
}
#[test]
fn enter_preserves_recovered_effect_order_and_initializes_heights() {
let mut state = state();
let mut frame = frame();
frame.start_height_484 = 3.0;
frame.velocity_400 = [-1.0, 0.0, 0.0, 0.0];
frame.start_flip_reference_96 = [1.0, 0.0, 0.0, 0.0];
let settings = settings();
let mode = mode();
let mut runtime = MockRuntime::new();
runtime.board_y = 4.0;
runtime.com = [0.0, 5.0, 0.0, 0.0];
runtime.targeting_grind = true;
runtime.closest = (11, [0.25, 0.0, 0.0, 0.0]);
enter(&mut state, &frame, &settings, &mode, &mut runtime);
assert_eq!(state.start_y_184, 3.0);
assert_eq!(state.max_y_188, 4.0);
assert_eq!(state.com_max_y_192, 5.0);
assert_eq!(state.trajectory_index_216, 11);
assert_eq!(state.trajectory_follow_offset_144, runtime.closest.1);
assert!(state.targeting_grind_213);
assert!(state.start_flipped_210);
assert_eq!(
&runtime.calls[..9],
[
"board.angular_only",
"collision.enabled:true",
"collision.state:7",
"skeleton.truck_tilt:false",
"skeleton.ik:true",
"reckoning.reset_flip",
"footplant.flag:false",
"footplant.reset",
"board.height",
]
);
assert!(runtime.calls.iter().any(|call| call == "grind.start"));
}
#[test]
fn exit_restores_velocity_before_selector_and_grind_cleanup() {
let mut state = state();
state.trajectory_index_216 = 0;
let mut frame = frame();
frame.next_physics_state_2500 = 100;
frame.ground_normal_464 = [0.0, 1.0, 0.0, 0.0];
let mut settings = settings();
settings.landing_speed_scalar_vs_ground_normal_y_240 = mock::flat_graph(2.0);
let mut reckoning = reckoning();
reckoning.body_spin_angle_1568 = 2.0;
reckoning.body_spin_speed_1572 = 3.0;
let mut runtime = MockRuntime::new();
runtime.restore_geometry = RestoreVelocityGeometry {
tangential_velocity: [2.0, 0.0, 0.0, 0.0],
landing_speed_curve_input: 0.0,
landing_speed_blend_source: 0.5,
};
runtime.board_velocity = [0.0, 1.0, 0.0, 0.0];
runtime.forward = [1.0, 0.0, 0.0, 0.0];
exit(
&mut state,
&mut frame,
&settings,
&mut reckoning,
&mut runtime,
);
assert_eq!(state.trajectory_index_216, 1);
assert_eq!(runtime.written_board_velocity, [3.0, 1.0, 0.0, 0.0]);
assert_eq!(frame.forward_speed_2612, 3.0);
assert_eq!(reckoning.body_spin_speed_1572, 0.0);
assert_eq!(reckoning.body_spin_angle_1568, 0.0);
let reset = runtime
.calls
.iter()
.position(|v| v == "reckoning.reset_flip")
.unwrap();
let write = runtime
.calls
.iter()
.position(|v| v == "board.set_velocity")
.unwrap();
let selector = runtime
.calls
.iter()
.position(|v| v == "selector.reset")
.unwrap();
let grind = runtime
.calls
.iter()
.position(|v| v == "grind.started:false")
.unwrap();
assert!(reset < write && write < selector && selector < grind);
}
#[test]
fn update_runs_footplant_grind_and_publication_in_native_order() {
let mut state = state();
state.time_in_state_180 = 0.2;
state.collision_time_196 = 0.25;
state.reached_apex_208 = true;
state.targeting_grind_213 = true;
state.trajectory_index_216 = 4;
let mut frame = frame();
frame.flags_2480 = 0x0400_0000;
frame.delta_time_2604 = 1.0 / 60.0;
let settings = settings();
let mode = mode();
let reckoning = reckoning();
let mut runtime = MockRuntime::new();
runtime.grind_started = true;
runtime.grind_adjusting = true;
runtime.board_y = 7.0;
runtime.com = [0.0, 8.0, 0.0, 0.0];
update(
&mut state,
&frame,
&settings,
&mode,
&reckoning,
&mut runtime,
);
assert!(state.grind_air_adjust_activated_212);
assert_eq!(state.trajectory_index_216, 5);
assert_close(state.time_in_state_180, 0.2 + frame.delta_time_2604);
assert_eq!(state.max_y_188, 7.0);
assert_eq!(state.com_max_y_192, 8.0);
let expected_bodies = ["3152", "3156", "3160", "3168", "3172", "3176"];
let actual_bodies: Vec<_> = runtime
.calls
.iter()
.filter_map(|call| call.strip_prefix("grind.disable:"))
.collect();
assert_eq!(actual_bodies, expected_bodies);
let foot = runtime
.calls
.iter()
.position(|v| v == "footplant.shift")
.unwrap();
let collision = runtime
.calls
.iter()
.position(|v| v == "collision.update")
.unwrap();
let reckon = runtime
.calls
.iter()
.position(|v| v == "reckoning.update_air")
.unwrap();
let skeleton = runtime
.calls
.iter()
.position(|v| v == "skeleton.update_known_air")
.unwrap();
let head = runtime
.calls
.iter()
.position(|v| v == "head.target:true")
.unwrap();
let steering = runtime
.calls
.iter()
.position(|v| v == "board.steering_zero")
.unwrap();
assert!(
foot < collision
&& collision < reckon
&& reckon < skeleton
&& skeleton < head
&& head < steering
);
}
#[test]
fn trajectory_follow_uses_selector_2704_and_honors_locked_index() {
let mut state = state();
state.trajectory_index_216 = 0;
state.time_in_state_180 = 2.0;
let mut frame = frame();
frame.flags_2468 = 0x0000_0008;
let settings = settings();
let mut runtime = MockRuntime::new();
runtime.prediction.trajectory.position = [100.0, 0.0, 0.0, 0.0];
runtime.selector_trajectory.position = [10.0, 0.0, 0.0, 0.0];
update_trajectory_follow(&mut state, &frame, &settings, &runtime);
assert_eq!(state.trajectory_index_216, 0);
assert_eq!(state.target_com_position_160, [10.0, 0.0, 0.0, 0.0]);
}
#[test]
fn auto_spin_uses_recovered_alignment_chain_and_manual_gate() {
let mut state = state();
state.landing_heading_valid_209 = true;
state.landing_normal_64 = [0.0, 1.0, 0.0, 0.0];
state.landing_heading_80 = [1.0, 0.0, 0.0, 0.0];
state.collision_time_196 = 1.0;
let mut frame = frame();
frame.delta_time_2604 = 1.0 / 60.0;
frame.skater_up_544 = [0.0, 1.0, 0.0, 0.0];
let mut settings = settings();
settings.max_heading_adjust_vs_up_y_160 = mock::flat_graph(180.0);
settings.min_auto_body_speed_424 = 0.01;
let mode = mode();
let reckoning = reckoning();
let mut runtime = MockRuntime::new();
runtime.signed_angle = 0.5;
let automatic =
calculate_body_spin_speed(&state, &frame, &settings, &mode, &reckoning, &mut runtime);
assert_close(automatic, 0.6);
frame.body_spin_input_2640 = 1.0;
frame.flags_2472 = 0;
runtime.calls.clear();
let masked_manual =
calculate_body_spin_speed(&state, &frame, &settings, &mode, &reckoning, &mut runtime);
assert_eq!(masked_manual, 0.0);
assert!(!runtime.calls.iter().any(|v| v == "math.signed_angle"));
}
#[test]
fn body_flip_starts_only_after_stock_flag_and_curve_gates() {
let mut state = state();
state.collision_time_196 = 2.0;
state.body_flip_target_speed_204 = -3.0;
let mut frame = frame();
frame.flags_2468 = 0x20 | 0x80;
let mut settings = settings();
settings.flip_start_collision_time_vs_normal_y = mock::flat_graph(1.0);
let mode = mode();
let reckoning = reckoning();
let mut runtime = MockRuntime::new();
let speed = calculate_body_flip_speed(
&mut state,
&frame,
&settings,
&mode,
&reckoning,
&mut runtime,
);
assert!(state.body_flipping_211);
assert_eq!(speed, -3.0);
assert!(
runtime
.calls
.iter()
.any(|v| v == "reckoning.begin_flip:true")
);
state.body_flipping_211 = false;
frame.flags_2488 = 0x0200_0000;
runtime.calls.clear();
calculate_body_flip_speed(
&mut state,
&frame,
&settings,
&mode,
&reckoning,
&mut runtime,
);
assert!(!state.body_flipping_211);
assert!(runtime.calls.is_empty());
}
#[test]
fn post_physics_sets_apex_then_requests_upside_down_wipeout() {
let mut state = state();
state.landing_normal_64 = [0.0, 1.0, 0.0, 0.0];
let mut frame = frame();
frame.skater_up_544 = [0.0, -1.0, 0.0, 0.0];
let mode = mode();
let wipeout_settings = KnownAirWipeoutSettings {
air_falling_min_up_y_260: 0.0,
air_falling_max_angle_264: 0.0,
};
let mut request = KnownAirWipeoutRequest {
requested_35: false,
scalar_116: 9.0,
counter_200: 4,
};
let mut runtime = MockRuntime::new();
runtime.board_velocity = [0.0, -2.0, 0.0, 0.0];
update_post_physics(
&mut state,
&frame,
&mode,
&wipeout_settings,
&mut request,
&mut runtime,
);
assert!(state.reached_apex_208);
assert!(request.requested_35);
assert_eq!(request.scalar_116, 0.0);
assert_eq!(request.counter_200, 5);
assert_eq!(runtime.calls[0], "board.velocity");
assert_eq!(runtime.calls[1], "wipeout.check:true");
}
#[test]
fn output_writes_all_samples_and_preserves_conditional_fields() {
let mut state = state();
state.trajectory_index_216 = 2;
state.collision_position_112 = [1.0, 0.0, 0.0, 0.0];
state.landing_normal_64 = [1.0, 0.0, 0.0, 0.0];
state.max_y_188 = 7.0;
state.start_y_184 = 2.0;
state.collision_time_196 = 3.0;
state.time_in_state_180 = 1.0;
let frame = frame();
let mut output = output();
output.locked_trajectory_velocity_valid_452 = true;
output.locked_trajectory_velocity_160 = [9.0; 4];
let mut runtime = MockRuntime::new();
runtime.prediction.trajectory.position = [0.0; 4];
runtime.prediction.trajectory.velocity = [60.0, 0.0, 0.0, 0.0];
runtime.selector_trajectory.position = [999.0, 0.0, 0.0, 0.0];
runtime.selector_com = [0.0; 4];
fill_physics_output(&state, &frame, &mut output, &mut runtime);
assert_eq!(output.locked_trajectory_velocity_160, [9.0; 4]);
assert!(output.locked_trajectory_velocity_valid_452);
assert_eq!(output.jump_height_200, 5.0);
assert_eq!(output.time_until_collision_184, 2.0);
assert_eq!(output.trajectory_position_64[0], 2.0);
assert_eq!(output.trajectory_plane_samples_336[0], 0.0);
assert_close(output.trajectory_plane_samples_336[24], 24.0);
assert!(output.known_air_valid_437);
}
@@ -0,0 +1,165 @@
//! KnownAir trajectory setup, ground restoration, and follow update.
use super::{
data::{
KnownAirFrame, KnownAirModeSettings, KnownAirSettings, KnownAirState, KnownAirTrajectory,
Vector4,
},
runtime::KnownAirRuntime,
};
const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 0x820849C8, 1/60.
const HALF: f32 = f32::from_bits(0x3F00_0000); // 0x8209975C.
const ONE: f32 = f32::from_bits(0x3F80_0000); // 0x8231A844.
const PERFECT_FLIP_NUMERATOR: f32 = f32::from_bits(0xC0F1_463B); // 0x822F9450.
const NEGATIVE_TWO_PI: f32 = f32::from_bits(0xC0C9_0FDB); // 0x822F9434.
const HEADING_LENGTH_SQUARED_MIN: f32 = f32::from_bits(0x3DCC_CCCD); // 0x820641A8.
/// `PhysState_KnownAir::InitTrajectoryInfo`, TU3 `0x82D35508`.
pub fn init_trajectory_info(
state: &mut KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
mode: &KnownAirModeSettings,
runtime: &mut impl KnownAirRuntime,
) {
state.landing_normal_64 = frame.selector_landing_normal_2656;
let prediction = runtime.selected_prediction();
let (apex, time_to_apex) = runtime.highest_trajectory_position(&prediction.trajectory);
state.time_to_apex_200 = time_to_apex;
state.trajectory_apex_96 = apex;
state.collision_position_112 = runtime.selector_contact_position();
//82D35580 reads r30+48; r30 is the complete winning result.
state.collision_time_196 = prediction.collision_time_48;
state.body_flip_target_speed_204 = if mode.perfect_body_flips_28 {
PERFECT_FLIP_NUMERATOR / state.collision_time_196
} else {
(settings.flip_scalar / state.collision_time_196) * NEGATIVE_TWO_PI
};
state.selector_vector_128 = runtime.selector_vector_2848();
let mut heading = [0.0; 4];
let mut heading_valid = false;
state.collision_normal_speed_176 = 0.0;
if state.collision_time_196 >= 0.0 {
let collision_frame = prediction.collision_frame_128;
let previous_time = collision_frame.wrapping_sub(2) as f32 * FIXED_STEP;
let collision_time = collision_frame.wrapping_sub(1) as f32 * FIXED_STEP;
let previous = trajectory_position(&prediction.trajectory, previous_time);
let collision = trajectory_position(&prediction.trajectory, collision_time);
let delta = sub(collision, previous);
let velocity = runtime.divide_vector_by_scalar(delta, FIXED_STEP);
state.collision_normal_speed_176 = runtime.dot3(state.landing_normal_64, velocity);
let normal_velocity = mul_scalar(state.landing_normal_64, state.collision_normal_speed_176);
let tangent = sub(velocity, normal_velocity);
let (normalized, length) = runtime.normalize_safe_with_length(tangent);
if !(settings.min_target_heading_velocity_420 > length) {
heading = normalized;
heading_valid = true;
}
}
state.landing_heading_80 = heading;
state.landing_heading_valid_209 = heading_valid;
let heading_length_squared = runtime.dot3(heading, heading);
if HEADING_LENGTH_SQUARED_MIN > heading_length_squared {
state.landing_heading_valid_209 = false;
}
}
/// Ground transition velocity restoration, TU3 `0x82D35998`.
pub fn restore_velocity(
state: &mut KnownAirState,
frame: &mut KnownAirFrame,
settings: &KnownAirSettings,
runtime: &mut impl KnownAirRuntime,
) {
let prediction = runtime.selected_prediction();
if state.trajectory_index_216 == 0 {
state.trajectory_index_216 = 1;
}
let sample_time = state.trajectory_index_216 as f32 * FIXED_STEP;
let trajectory_velocity = trajectory_velocity(&prediction.trajectory, sample_time);
let geometry =
runtime.restore_velocity_geometry_82d35998(trajectory_velocity, frame.ground_normal_464);
let curve_value = settings
.landing_speed_scalar_vs_ground_normal_y_240
.evaluate(geometry.landing_speed_curve_input);
let lower_clamped = select(
-geometry.landing_speed_blend_source,
0.0,
geometry.landing_speed_blend_source,
);
let blend_weight = select(ONE - lower_clamped, lower_clamped, ONE);
let speed_scale = (curve_value - ONE).mul_add(blend_weight, ONE);
let scaled_tangent = mul_scalar(geometry.tangential_velocity, speed_scale);
let current_board_velocity = runtime.board_body_velocity();
let preserved_normal_speed = runtime.dot3(current_board_velocity, frame.ground_normal_464);
let restored = core::array::from_fn(|lane| {
frame.ground_normal_464[lane].mul_add(preserved_normal_speed, scaled_tangent[lane])
});
runtime.set_board_velocity(restored);
let forward = runtime.board_forward_axis();
frame.forward_speed_2612 = runtime.dot3(restored, forward);
}
/// `PhysState_KnownAir::UpdateTrajectoryFollow`, TU3 `0x82D36460`.
pub fn update_trajectory_follow(
state: &mut KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
runtime: &impl KnownAirRuntime,
) {
state.trajectory_index_216 = state.trajectory_index_216.wrapping_add(1);
let ratio = (settings.trajectory_error_blend_away_time_384 - state.time_in_state_180)
/ settings.trajectory_error_blend_away_time_384;
let lower_clamped = select(-ratio, 0.0, ratio);
let blend = select(ONE - lower_clamped, lower_clamped, ONE);
let sample_time = state.trajectory_index_216 as f32 * FIXED_STEP;
let trajectory = runtime.selector_trajectory_2704();
let trajectory_position = trajectory_position(&trajectory, sample_time);
state.target_com_position_160 = core::array::from_fn(|lane| {
state.trajectory_follow_offset_144[lane].mul_add(blend, trajectory_position[lane])
});
if frame.flags_2468 & 0x0000_0008 != 0 {
state.trajectory_index_216 = state.trajectory_index_216.wrapping_sub(1);
}
}
pub(crate) fn trajectory_position(trajectory: &KnownAirTrajectory, time: f32) -> Vector4 {
let time_squared = time * time;
core::array::from_fn(|lane| {
let linear = trajectory.velocity[lane].mul_add(time, trajectory.position[lane]);
let half_acceleration = trajectory.acceleration[lane] * HALF;
half_acceleration.mul_add(time_squared, linear)
})
}
pub(crate) fn trajectory_velocity(trajectory: &KnownAirTrajectory, time: f32) -> Vector4 {
core::array::from_fn(|lane| {
trajectory.acceleration[lane].mul_add(time, trajectory.velocity[lane])
})
}
pub(crate) fn add(left: Vector4, right: Vector4) -> Vector4 {
core::array::from_fn(|lane| left[lane] + right[lane])
}
pub(crate) fn sub(left: Vector4, right: Vector4) -> Vector4 {
core::array::from_fn(|lane| left[lane] - right[lane])
}
pub(crate) fn mul_scalar(vector: Vector4, scalar: f32) -> Vector4 {
vector.map(|lane| lane * scalar)
}
pub(crate) fn select(test: f32, nonnegative: f32, negative: f32) -> f32 {
if test >= 0.0 { nonnegative } else { negative }
}
+106
View File
@@ -0,0 +1,106 @@
//! Full outer TU3 KnownAir update in recovered call order.
use super::{
body::{calculate_body_flip_speed, calculate_body_spin_speed},
data::{
GrindFootBody, KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields,
KnownAirSettings, KnownAirState,
},
footplant::update_footplant,
runtime::KnownAirRuntime,
trajectory::{init_trajectory_info, select, update_trajectory_follow},
};
const ALIGNMENT_LEAD_FRAMES: f32 = f32::from_bits(0x4040_0000); // 0x82063B08, 3.
const MIN_ALIGNMENT_FRAMES: f32 = f32::from_bits(0x4000_0000); // 0x82060C50, 2.
const ONE: f32 = f32::from_bits(0x3F80_0000); // 0x8231A844.
const BOARD_ERROR_WINDOW: f32 = f32::from_bits(0x3D75_C28F); // 0x82099460, 0.06.
const ZERO: f32 = 0.0; // 0x82165A10.
const GRIND_FOOT_BODIES: [GrindFootBody; 6] = [
GrindFootBody::Body3152,
GrindFootBody::Body3156,
GrindFootBody::Body3160,
GrindFootBody::Body3168,
GrindFootBody::Body3172,
GrindFootBody::Body3176,
];
/// `PhysState_KnownAir::Update`, TU3 `0x82D35C58`.
pub fn update(
state: &mut KnownAirState,
frame: &KnownAirFrame,
settings: &KnownAirSettings,
mode: &KnownAirModeSettings,
reckoning: &KnownAirReckoningFields,
runtime: &mut impl KnownAirRuntime,
) {
update_footplant(state, frame, runtime);
if runtime.selector_has_just_changed() {
init_trajectory_info(state, frame, settings, mode, runtime);
}
runtime.update_air_collision();
let body_flip_speed =
calculate_body_flip_speed(state, frame, settings, mode, reckoning, runtime);
let body_spin_speed =
calculate_body_spin_speed(state, frame, settings, mode, reckoning, runtime);
let remaining_frames =
(state.collision_time_196 - state.time_in_state_180) / frame.delta_time_2604;
let after_lead = remaining_frames - ALIGNMENT_LEAD_FRAMES;
let alignment_frames = select(
after_lead - MIN_ALIGNMENT_FRAMES,
after_lead,
MIN_ALIGNMENT_FRAMES,
);
if !state.grind_air_adjust_activated_212
&& (state.reached_apex_208 || alignment_frames < settings.frames_for_grind_air_assist_436)
{
let started = runtime.grind_air_adjust_started();
runtime.set_grind_air_adjust_activated(started);
state.grind_air_adjust_activated_212 = true;
}
if state.targeting_grind_213
&& state.grind_air_adjust_activated_212
&& runtime.grind_air_adjust_adjusting()
{
for body in GRIND_FOOT_BODIES {
runtime.disable_grind_foot_collision(body);
}
}
runtime.update_reckoning_air_states(
state.landing_normal_64,
ONE / alignment_frames,
body_spin_speed,
body_flip_speed,
);
update_trajectory_follow(state, frame, settings, runtime);
runtime.update_known_air_skeleton(state.target_com_position_160);
if state.time_in_state_180 < BOARD_ERROR_WINDOW {
runtime.set_skeleton_add_skateboard_error(true);
}
let head_target = if frame.flags_2472 & 0x0040_0000 != 0 {
frame.alternate_head_target_112
} else {
state.collision_position_112
};
runtime.set_head_tracking_target(head_target, true);
runtime.update_board_steering_tilt(ZERO);
let board_y = runtime.board_transform_height();
if !(state.max_y_188 > board_y) {
state.max_y_188 = board_y;
}
if frame.flags_2468 & 0x0000_0008 == 0 {
state.time_in_state_180 += frame.delta_time_2604;
}
let com_y = runtime.skeleton_com_position()[1];
state.com_max_y_192 = select(state.com_max_y_192 - com_y, state.com_max_y_192, com_y);
}
+9
View File
@@ -0,0 +1,9 @@
//! Recovered airborne reckoning units. Air-state scheduling remains a caller boundary.
pub mod body_flip;
pub mod body_spin;
pub mod ground_normal;
pub mod ground_jump;
pub mod known;
pub mod reckoning;
pub mod state;
pub mod trajectory;
@@ -0,0 +1,75 @@
//! Additional persistent Reckoning fields and actual inputs for82D8DBD8.
use crate::{
air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings},
physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY},
point_graph::PointGraph,
};
#[derive(Clone, Debug, PartialEq)]
pub struct AirState {
///1568/1572, shared with Ground entry and PhysicsAir exit.
pub spin_angle: f32,
pub spin_speed: f32,
///1580 is cleared by UpdateAirStates (1576 lives in ReckoningFrames).
pub secondary_lean_angle: f32,
pub flip_angle: f32,
pub flip_speed: f32,
pub flip_requested_speed: f32,
///1072; final combined1008 has one owner, ReckoningFrames.body_flip.
pub spin_transform: AnimationPartTransform,
///1232 and1596; their native trajectory/entry producers remain the caller.
pub flip_axis: [f32; 4],
pub flip_active: bool,
///1597, BeginBodyFlip82D8EC60 and ResetBodyFlip82D8EAA4.
pub flip_side: bool,
}
impl AirState {
///82D33178: scalar zeros, identity1072, world-X axis and inactive flip.
pub fn new() -> Self {
Self {
spin_angle: 0.0,
spin_speed: 0.0,
secondary_lean_angle: 0.0,
flip_angle: 0.0,
flip_speed: 0.0,
flip_requested_speed: 0.0,
spin_transform: IDENTITY,
flip_axis: [1.0, 0.0, 0.0, 0.0],
flip_active: false,
flip_side: false,
}
}
///Ground entry82D37538/PhysicsAir exit82D346A0 reset exactly this pair.
pub fn reset_spin(&mut self) {
self.spin_angle = 0.0;
self.spin_speed = 0.0;
}
}
pub struct Settings {
pub ground_normal_smoothing: [f32; 4],
///PointNegGraphData8 layout336; caller directly evaluates its X/Y352/384.
pub max_up_angle_delta: PointGraph<8>,
pub tilt_vs_rotation: PointGraph<8>,
pub tilt_vs_slope: PointGraph<8>,
pub body_spin: BodySpinSettings,
pub body_flip: BodyFlipSettings,
}
#[derive(Clone, Copy, Debug)]
pub struct Input {
pub landing_normal: [f32; 4],
pub normal_blend: f32,
pub target_spin: f32,
pub flip_request: f32,
///Processed752, from the actual Skeleton animation COM-to-deck producer.
pub com_to_deck: [f32; 4],
pub timestep: f32,
///Processed2812, live PhysBodySpin animation attribute.
pub physical_body_spin: f32,
///Processed2644, produced by Skeleton82BDD008; used only when2484bit15 is set.
pub grind_adjusted_body_spin: f32,
pub additive_spin: bool,
pub direct_spin: bool,
pub reverse_stance: bool,
///Actual selected physics_mode layout108 and28.
pub easy_body_spins: bool,
pub perfect_body_flips: bool,
}
+105
View File
@@ -0,0 +1,105 @@
//! Original TU3 angle limiter82BD3E78 and Air heading rotation82D8DFD4.
//! Basic arithmetic runs on the host; this is not bit-exact Xenon emulation.
use crate::{
math::Vector3,
physics::{
board_ground::angle_between,
board_motion_output::inverse_length_squared,
native_arithmetic::{dot3, vector_max, vector_min},
},
trigonometry,
};
pub(super) type V = [f32; 4];
const EPSILON: f32 = f32::from_bits(0x3586_37bd);
const TAU: f32 = f32::from_bits(0x40c9_0fdb);
const INV_TAU: f32 = f32::from_bits(0x3e22_f983);
pub(super) fn angle(a: V, b: V) -> f32 {
angle_between(
Vector3::new(a[0], a[1], a[2]),
Vector3::new(b[0], b[1], b[2]),
)
}
pub(super) fn wrap(angle: f32) -> f32 {
let turns = angle * INV_TAU;
let fraction = turns - turns.floor();
(fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * TAU
}
pub(super) fn length(v: V) -> f32 {
let sq = dot3(v, v);
let inverse = inverse_length_squared(sq, 2);
if sq == 0.0 { 0.0 } else { sq * inverse }
}
pub(super) fn normalize(v: V) -> V {
let inverse = inverse_length_squared(dot3(v, v), 2);
v.map(|x| x * inverse)
}
pub(super) fn normalize_safe(v: V, fallback: V) -> V {
let sq = dot3(v, v);
let inverse = inverse_length_squared(sq, 2);
let magnitude = if sq == 0.0 { 0.0 } else { sq * inverse };
if magnitude > EPSILON {
v.map(|x| x * inverse)
} else {
fallback
}
}
fn cross(a: V, b: V) -> V {
[
(-a[2]).mul_add(b[1], a[1] * b[2]),
(-a[0]).mul_add(b[2], a[2] * b[0]),
(-a[1]).mul_add(b[0], a[0] * b[1]),
(-a[3]).mul_add(b[3], a[3] * b[3]),
]
}
///82BD3E78. Retains the source's original (not normalized) endpoint operands
/// and final target magnitude. The parallel-axis branch returns target as-is.
pub fn limit_angle(target: V, from: V, maximum: f32) -> V {
let normalized_from = normalize_safe(from, [0.0; 4]);
let normalized_target = normalize_safe(target, [0.0; 4]);
let axis = cross(normalized_target, normalized_from);
let cosine = vector_min(
vector_max(dot3(normalized_target, normalized_from), -1.0),
1.0,
);
let angle = trigonometry::acos(cosine);
//The limiter uses floor(angle/tau + .5), unlike its caller's fraction wrap.
let turns = angle.mul_add(INV_TAU, 0.5).floor();
let closest_angle = (-turns).mul_add(TAU, angle);
if closest_angle.abs() < maximum || dot3(axis, axis) < f32::from_bits(0x3780_0000) {
return target;
}
let axis = normalize(axis);
let (sin, cos) = trigonometry::sin_cos(-maximum * 0.5);
let q = axis.map(|v| v * sin);
let first = cross(q, from);
let intermediate = std::array::from_fn(|i| cos.mul_add(from[i], first[i]));
let second = cross(q, intermediate);
let rotated: V = std::array::from_fn(|i| second[i].mul_add(2.0, from[i]));
let magnitude = length(target);
rotated.map(|v| v * magnitude)
}
///82D8DFD4..E0B0: Rodrigues columns followed by X/Y/Z ordered FMAs.
///The native permutation repeats each column's X into its W lane.
pub(super) fn rotate_heading(heading: V, up: V, angle: f32) -> V {
let [x, y, z, _] = up;
let (s, c) = trigonometry::sin_cos(angle);
let t = 1.0 - c;
let (tx, ty, tz) = (t * x, t * y, t * z);
let (sx, sy, sz) = (s * x, s * y, s * z);
let xx = tx.mul_add(x, c);
let yx = ty * x - sz;
let zx = tz.mul_add(x, sy);
let columns = [
[xx, tx.mul_add(y, sz), tx * z - sy, xx],
[yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx],
[zx, tz * y - sx, tz.mul_add(z, c), zx],
];
std::array::from_fn(|i| {
let first = columns[0][i] * heading[0];
let second = columns[1][i].mul_add(heading[1], first);
columns[2][i].mul_add(heading[2], second)
})
}
+129
View File
@@ -0,0 +1,129 @@
//! Complete original TU3 Reckoning::UpdateAirStates82D8DBD8.
//! Shared Ground filters, frame owners and BodySpin history persist across states.
mod data;
mod math;
use crate::{
air::{
body_flip::{self, BodyFlipInput, BodyFlipState},
body_spin::{self, BodySpinState},
},
math::Vector3,
riding::{ground_orientation::GroundOrientation, reckoning_frames::ReckoningFrames},
};
pub use data::{AirState, Input, Settings};
pub use math::limit_angle as clamp_vector_within_max_angle;
use math::{angle, length, limit_angle, normalize, normalize_safe, rotate_heading, wrap};
pub fn update(
orientation: &mut GroundOrientation,
frames: &mut ReckoningFrames,
body_spin: &mut BodySpinState,
state: &mut AirState,
settings: &Settings,
input: &Input,
) {
let old_up = lanes(orientation.up);
orientation.dynamic_up = orientation.up;
frames.target_lean_angle = 0.0;
state.secondary_lean_angle = 0.0;
let ground_normal = orientation
.ground_filter
.update(settings.ground_normal_smoothing, input.landing_normal);
orientation.ground_normal = vector(ground_normal);
state.spin_speed = if input.additive_spin {
input.grind_adjusted_body_spin + input.target_spin
} else if input.direct_spin {
input.target_spin
} else {
body_spin::update(
body_spin,
&settings.body_spin,
input.physical_body_spin,
input.target_spin,
true,
u8::from(input.easy_body_spins),
);
body_spin.speed()
};
state.spin_angle = state
.spin_speed
.mul_add(f32::from_bits(0x3c88_8889), state.spin_angle);
if state.flip_active {
//A short call-boundary view, not a second retained owner of matrix1008.
let mut flip = BodyFlipState {
angle: state.flip_angle,
speed: state.flip_speed,
requested_speed: state.flip_requested_speed,
spin_transform: state.spin_transform,
combined_transform: frames.body_flip,
};
body_flip::update(
&mut flip,
&settings.body_flip,
&BodyFlipInput {
requested_speed: input.flip_request,
spin_angle: state.spin_angle,
normal: old_up,
flip_axis: state.flip_axis,
timestep: input.timestep,
perfect_body_flips: input.perfect_body_flips,
},
);
state.flip_angle = flip.angle;
state.flip_speed = flip.speed;
state.flip_requested_speed = flip.requested_speed;
state.spin_transform = flip.spin_transform;
frames.body_flip = flip.combined_transform;
}
let wrapped = wrap(angle(old_up, ground_normal));
let maximum = wrapped * input.normal_blend;
let target = if wrapped.abs() >= f32::from_bits(0x40c8_f61e) {
if old_up[1].abs() < f32::from_bits(0x3f7d_70a4) {
[0.0, 1.0, 0.0, 0.0]
} else {
[1.0, 0.0, 0.0, 0.0]
}
} else {
ground_normal
};
let candidate = normalize(limit_angle(target, old_up, maximum));
let max_delta = settings
.max_up_angle_delta
.evaluate(length(input.com_to_deck));
let selected = if wrap(angle(candidate, old_up)).abs() > max_delta {
limit_angle(candidate, old_up, max_delta)
} else {
candidate
};
let up = normalize_safe(selected, old_up);
orientation.up_velocity = vector(std::array::from_fn(|i| up[i] - old_up[i]));
orientation.up = vector(up);
orientation.target = orientation.up;
if !state.flip_active {
frames.heading = rotate_heading(frames.heading, up, input.timestep * state.spin_speed);
}
//82D8C868 advances histories using retained control words, then publishes
//the chosen up into each current-position slot; no filter reinitialization.
orientation.slow_filter.filter(up);
orientation.fast_filter.filter(up);
orientation.slow_filter.publish_current(up);
orientation.fast_filter.publish_current(up);
frames.calculate_transform(up, ground_normal);
frames.calculate_tilt(
input.reverse_stance,
&settings.tilt_vs_rotation,
&settings.tilt_vs_slope,
);
}
fn lanes(v: Vector3) -> [f32; 4] {
[v.x, v.y, v.z, 0.0]
}
fn vector(v: [f32; 4]) -> Vector3 {
Vector3::new(v[0], v[1], v[2])
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,176 @@
use super::*;
use crate::{
air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings},
physics::skeleton_animation_record::IDENTITY,
point_graph::PointGraph,
riding::ground_orientation::GroundOrientationSettings,
};
fn curve(value: f32) -> PointGraph<8> {
PointGraph {
x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
y: [value; 8],
}
}
fn orientation() -> GroundOrientation {
GroundOrientation::new(&GroundOrientationSettings {
ground_normal_smoothing: [1.0, 0.0, 0.0, 1.0],
up_vector_smoothing_slow: [0.2, 0.1, 0.05, 0.3],
up_vector_smoothing_fast: [0.7, 0.2, 0.1, 0.8],
dynamic_up_vs_ground_y: curve(0.0),
ground_vector_blend: curve(0.0),
deck_angle_usage_vs_speed: curve(0.0),
up_vector_smoothing_vs_speed: curve(0.0),
up_vector_max_delta_vs_speed: curve(1.0),
ground_blend_max_delta: 0.0,
up_vector_max_acceleration: 1.0,
anti_wobble_damping: 0.5,
extra_side_damping: 0.0,
minimum_wheels_for_ground_blend: 1,
})
}
fn settings() -> Settings {
Settings {
ground_normal_smoothing: [1.0, 0.0, 0.0, 1.0],
max_up_angle_delta: curve(0.5),
tilt_vs_rotation: curve(0.0),
tilt_vs_slope: curve(0.0),
body_spin: BodySpinSettings {
derivative_floor: 0.4,
acceleration_limit: 0.2,
curves: [curve(1.0); 7],
input_fade_threshold: [f32::from_bits(0x37800000); 4],
},
body_flip: BodyFlipSettings {
smoothing: Some(0.1),
maximum_speed: Some(5.0),
spin_scale: Some(0.5),
missing_attribute_value: 0.0,
},
}
}
fn input() -> Input {
Input {
landing_normal: [0.0, 1.0, 0.0, 0.0],
normal_blend: 0.1,
target_spin: 0.0,
flip_request: 0.0,
com_to_deck: [0.0, 1.0, 0.0, 0.0],
timestep: 1.0 / 60.0,
physical_body_spin: 0.0,
grind_adjusted_body_spin: 0.0,
additive_spin: false,
direct_spin: true,
reverse_stance: false,
easy_body_spins: true,
perfect_body_flips: false,
}
}
#[test]
fn angle_limit_rotates_from_toward_target_and_preserves_parallel_branch() {
let target = [0.0, 0.0, 1.0, 0.0];
let from = [1.0, 0.0, 0.0, 0.0];
let result = math::limit_angle(target, from, std::f32::consts::FRAC_PI_4);
assert!((result[0] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.0001);
assert!((result[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.0001);
assert_eq!(
math::limit_angle([-2.0, 0.0, 0.0, 0.0], from, 0.1),
[-2.0, 0.0, 0.0, 0.0]
);
}
#[test]
fn direct_and_additive_spin_use_fixed_angle_step_and_keep_body_spin_history() {
let mut orientation = orientation();
let mut frames = ReckoningFrames::new();
let mut spin = BodySpinState::new();
let mut state = AirState::new();
let mut input = input();
input.target_spin = 2.0;
input.timestep = 0.1;
let old_history = *spin.words();
update(
&mut orientation,
&mut frames,
&mut spin,
&mut state,
&settings(),
&input,
);
assert_eq!(state.spin_angle, 2.0 * f32::from_bits(0x3c888889));
assert_eq!(spin.words(), &old_history);
//Heading uses caller dt even though accumulated spin angle uses1/60.
assert!((frames.heading[0] - 0.2_f32.cos()).abs() < 0.0001);
assert!((frames.heading[2] + 0.2_f32.sin()).abs() < 0.0001);
input.additive_spin = true;
input.grind_adjusted_body_spin = 3.0;
update(
&mut orientation,
&mut frames,
&mut spin,
&mut state,
&settings(),
&input,
);
assert_eq!(state.spin_speed, 5.0);
assert_eq!(spin.words(), &old_history);
}
#[test]
fn airborne_filter_update_retains_controls_and_publishes_current_normal() {
let mut orientation = orientation();
let mut frames = ReckoningFrames::new();
let mut spin = BodySpinState::new();
let mut state = AirState::new();
let mut input = input();
let slow_control: Vec<_> = orientation.slow_filter.words()[..4].to_vec();
let fast_control: Vec<_> = orientation.fast_filter.words()[..4].to_vec();
input.landing_normal = [0.0, 0.8, 0.6, 0.0];
update(
&mut orientation,
&mut frames,
&mut spin,
&mut state,
&settings(),
&input,
);
assert!(orientation.ground_normal.z > 0.59);
assert!(orientation.up.z > 0.0 && orientation.up.z < orientation.ground_normal.z);
assert_eq!(
&orientation.slow_filter.words()[..4],
slow_control.as_slice()
);
assert_eq!(
&orientation.fast_filter.words()[..4],
fast_control.as_slice()
);
assert_eq!(
&orientation.slow_filter.words()[4..8],
&lanes(orientation.up).map(f32::to_bits)
);
assert_eq!(frames.ground[1], lanes(orientation.ground_normal));
}
#[test]
fn active_zero_flip_preserves_combined_frame_and_spin_reset_preserves_flip() {
let mut orientation = orientation();
let mut frames = ReckoningFrames::new();
let mut spin = BodySpinState::new();
let mut state = AirState::new();
let mut input = input();
state.flip_active = true;
input.target_spin = 1.0;
update(
&mut orientation,
&mut frames,
&mut spin,
&mut state,
&settings(),
&input,
);
assert_eq!(frames.body_flip, IDENTITY);
assert_eq!(frames.heading, [1.0, 0.0, 0.0, 0.0]);
state.flip_angle = 0.3;
state.flip_speed = 0.2;
state.reset_spin();
assert_eq!([state.spin_angle, state.spin_speed], [0.0; 2]);
assert_eq!([state.flip_angle, state.flip_speed], [0.3, 0.2]);
assert!(state.flip_active);
}
+32
View File
@@ -0,0 +1,32 @@
//! TU3 PhysicsAir board update (`0x82D34AB0`).
use super::{
data::{AirBoardForce, PhysicsAirFrame, PhysicsAirReckoningFields, PhysicsAirState},
jump_velocity::calculate_velocity_from_jump,
runtime::PhysicsAirRuntime,
};
const AIR_COLLISION_FORCE_TAG: u32 = 15;
pub fn update_skateboard(
state: &PhysicsAirState,
frame: &PhysicsAirFrame,
reckoning: &PhysicsAirReckoningFields,
runtime: &mut impl PhysicsAirRuntime,
) {
runtime.update_board_steering_tilt(0.0);
let mut force = AirBoardForce::ZERO;
let collision_force_created = runtime
.calculate_air_collision_force(reckoning.collision_reference_normal_1216, &mut force);
if collision_force_created {
runtime.enqueue_board_force(AIR_COLLISION_FORCE_TAG, force);
return;
}
if !state.use_centre_of_mass_velocity && frame.frames_since_jump_correction_2576 < 12 {
let velocity =
calculate_velocity_from_jump(frame, frame.frames_since_jump_correction_2576, runtime);
runtime.set_board_velocity(velocity);
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Persistent fields and direct inputs used by TU3 `PhysState_PhysicsAir`.
use crate::point_graph::PointGraph;
pub type Vector4 = [f32; 4];
/// Native trajectory storage at PhysicsAir+80.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AirTrajectory {
pub position: Vector4,
pub velocity: Vector4,
pub acceleration: Vector4,
/// Native trajectory +48. Both recovered initializers write -1.0.
pub scalar_48: f32,
}
/// Fields owned by the TU3 PhysicsAir object. Enter preserves prior trajectory
/// storage when COM mode is not selected; construct once and retain this owner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhysicsAirState {
/// PhysicsAir+80.
pub centre_of_mass_trajectory: AirTrajectory,
/// PhysicsAir+144.
pub landing_normal: Vector4,
/// PhysicsAir+160.
pub time_in_state: f32,
/// PhysicsAir+164/+168.
pub start_y: f32,
pub max_y: f32,
/// PhysicsAir+172/+173/+174.
pub reached_apex: bool,
pub use_centre_of_mass_velocity: bool,
/// The producer is selector+9662. A more specific stock name is unresolved.
pub selector_latch_174: bool,
/// PhysicsAir+176, decremented as a signed wrapping integer.
pub trajectory_query_countdown: i32,
}
impl Default for PhysicsAirState {
///Original constructor82D341D0: zero vectors80/96/112, -1 at128,
///up144, zero160/164/168 and172..176. Entry is a separate operation.
fn default() -> Self {
Self {
centre_of_mass_trajectory: AirTrajectory {
position: [0.0; 4], velocity: [0.0; 4], acceleration: [0.0; 4], scalar_48: -1.0,
},
landing_normal: [0.0, 1.0, 0.0, 0.0],
time_in_state: 0.0, start_y: 0.0, max_y: 0.0,
reached_apex: false, use_centre_of_mass_velocity: false,
selector_latch_174: false, trajectory_query_countdown: 0,
}
}
}
/// Direct ProcessedPhysIn values consumed by the recovered methods.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhysicsAirFrame {
pub current_velocity_400: Vector4,
/// Y lane of the all-states ground position, read directly at +500.
pub ground_position_y_500: f32,
pub trajectory_position_592: Vector4,
pub trajectory_velocity_608: Vector4,
pub jump_velocity_848: Vector4,
pub flags_2468: u32,
pub previous_physics_state_2504: i32,
pub previous_physics_category_2516: i32,
pub frames_since_jump_correction_2576: i32,
pub delta_time_2604: f32,
pub body_spin_input_2640: f32,
pub gravity_y_2648: f32,
pub state_timer_2664: f32,
}
/// Cached collection values read at PhysicsAir settings +320..+444.
/// No retail defaults are embedded; the stock vault adapter must provide them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhysicsAirSettings {
/// PointGraph x at +320 and y at +352.
pub body_spin_over_time_320: PointGraph<8>,
pub landing_normal_blend_388: f32,
pub body_spin_scale_428: f32,
pub landing_normal_angle_limit_444: f32,
}
/// Reckoning fields read directly by PhysicsAir, apart from the full
/// `Reckoning::UpdateAirStates` effect exposed by the runtime interface.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhysicsAirReckoningFields {
pub current_landing_normal_1152: Vector4,
pub collision_reference_normal_1216: Vector4,
pub body_spin_angle_1568: f32,
pub body_spin_speed_1572: f32,
}
/// Native zero-initialized point-force record passed to `0x82D944E8`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AirBoardForce {
pub force_world: Vector4,
pub point_board_local: Vector4,
}
impl AirBoardForce {
pub const ZERO: Self = Self {
force_world: [0.0; 4],
point_board_local: [0.0; 4],
};
}
/// Writes performed by `FillPhysOut` (`0x82D34E90`). `scalar_184_write` is
/// `None` when native code leaves the existing PhysOut field untouched.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PhysicsAirOutput {
pub is_at_apex: bool,
pub jump_height: f32,
pub landing_normal: Vector4,
pub scalar_184_write: Option<f32>,
}
@@ -0,0 +1,65 @@
//! TU3 PhysicsAir jump-velocity correction (`0x82D34BC0`).
use super::data::{PhysicsAirFrame, Vector4};
const VERTICAL_ERROR_FLOOR: f32 = f32::from_bits(0xC040_0000); // -3.0
const HORIZONTAL_ERROR_LIMIT_SQUARED: f32 = f32::from_bits(0x4110_0000); // 9.0
/// Numerical operations used by the recovered formula. The PC adapter uses
/// independently implemented arithmetic and the original refinement steps;
/// agreement with every Xenon floating-point edge case is not established.
pub trait PhysicsAirMath {
/// Native `vminfp128`, used by the OffBoard air-entry velocity cap.
fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32;
/// Native `vmsum3fp128(value, value)`; only lane zero is consumed.
fn length_squared_vmsum3fp(&mut self, value: Vector4) -> f32;
/// The native vmsum/vrsqrte/two-refinement sequence at 82D34D10..68.
fn length_vmsum3fp_vrsqrte(&mut self, value: Vector4) -> f32;
/// `PhysicsUtility::ClampVectorWithinMaxLength`, `0x82BD3D90`.
fn clamp_vector_within_max_length(&mut self, value: Vector4, maximum_length: f32) -> Vector4;
}
/// Reconstructs the full branch contract and arithmetic order of `0x82D34BC0`.
/// The frame count is signed in the caller and in the target's `extsw/fcfid`.
pub fn calculate_velocity_from_jump(
frame: &PhysicsAirFrame,
frames: i32,
math: &mut impl PhysicsAirMath,
) -> Vector4 {
let frame_seconds = frame.delta_time_2604 * frames as f32;
let gravity_delta = frame_seconds * frame.gravity_y_2648;
let mut predicted = frame.jump_velocity_848;
predicted[0] += 0.0;
predicted[1] += gravity_delta;
predicted[2] += 0.0;
predicted[3] += 0.0;
let current = frame.current_velocity_400;
let mut error = core::array::from_fn(|lane| current[lane] - predicted[lane]);
let mut output = predicted;
// `vcmpgtfp(error.y, -3.0)`: false, including equality/unordered, selects
// the current vertical velocity.
if !(error[1] > VERTICAL_ERROR_FLOOR) {
output[1] = current[1];
}
error[1] = 0.0;
let horizontal_error_squared = math.length_squared_vmsum3fp(error);
// Native fcmpu+blt skips correction only for an ordered value below 9.0.
if !(horizontal_error_squared < HORIZONTAL_ERROR_LIMIT_SQUARED) {
let mut predicted_horizontal = output;
predicted_horizontal[1] = 0.0;
let maximum_length = math.length_vmsum3fp_vrsqrte(predicted_horizontal);
let mut current_horizontal = current;
current_horizontal[1] = 0.0;
let clamped = math.clamp_vector_within_max_length(current_horizontal, maximum_length);
output[0] = clamped[0];
output[2] = clamped[2];
}
output
}
@@ -0,0 +1,95 @@
//! TU3 PhysicsAir Enter/Exit/output lifecycle.
use super::{
data::{
AirTrajectory, PhysicsAirFrame, PhysicsAirOutput, PhysicsAirReckoningFields,
PhysicsAirState, Vector4,
},
jump_velocity::calculate_velocity_from_jump,
runtime::PhysicsAirRuntime,
};
const TRAJECTORY_UNBOUNDED: f32 = f32::from_bits(0xBF80_0000); // -1.0
const OFFBOARD_VERTICAL_SCALE: f32 = f32::from_bits(0x3F26_6666); // 0.65
const OFFBOARD_VERTICAL_LIMIT: f32 = f32::from_bits(0x4040_0000); // 3.0
const OUTPUT_SCALAR_184: f32 = f32::from_bits(0x4080_0000); // 4.0
/// Full TU3 `PhysState_PhysicsAir::Enter`, `0x82D34388`.
pub fn enter(
state: &mut PhysicsAirState,
frame: &PhysicsAirFrame,
world_acceleration: Vector4,
runtime: &mut impl PhysicsAirRuntime,
) {
runtime.set_air_collision_update_enabled(true);
runtime.set_skeleton_collision_state(7);
runtime.set_skeleton_inverse_kinematics_enabled(true);
runtime.set_skeleton_collision_state(6);
runtime.enable_board_angular_drive_only();
runtime.set_footplant_flag_240(false);
runtime.reset_footplants();
state.selector_latch_174 = false;
state.reached_apex = false;
state.trajectory_query_countdown = 0;
state.landing_normal = [0.0, 1.0, 0.0, 0.0];
state.time_in_state = 0.0;
state.max_y = runtime.board_transform_height();
state.start_y = frame.ground_position_y_500;
let (use_centre_of_mass_velocity, request_heading_update) = initial_centre_of_mass_mode(frame);
state.use_centre_of_mass_velocity = use_centre_of_mass_velocity;
if use_centre_of_mass_velocity {
let mut velocity = frame.trajectory_velocity_608;
if frame.previous_physics_state_2504 == 500 {
let scaled_y = velocity[1] * OFFBOARD_VERTICAL_SCALE;
velocity[1] = runtime.minimum_vminfp(scaled_y, OFFBOARD_VERTICAL_LIMIT);
}
if frame.frames_since_jump_correction_2576 < 12 {
velocity = calculate_velocity_from_jump(
frame,
frame.frames_since_jump_correction_2576,
runtime,
);
}
state.centre_of_mass_trajectory = AirTrajectory {
position: frame.trajectory_position_592,
velocity,
acceleration: world_acceleration,
scalar_48: TRAJECTORY_UNBOUNDED,
};
}
// This branch-local flag deliberately differs from the COM selection in
// the grind/category-400 path.
if request_heading_update {
runtime.request_skeleton_heading_update();
}
}
/// TU3 `PhysState_PhysicsAir::Exit`, `0x82D346A0`.
pub fn exit(state: &mut PhysicsAirState, reckoning: &mut PhysicsAirReckoningFields) {
state.use_centre_of_mass_velocity = false;
reckoning.body_spin_speed_1572 = 0.0;
reckoning.body_spin_angle_1568 = 0.0;
}
/// TU3 `PhysState_PhysicsAir::FillPhysOut`, `0x82D34E90`.
pub fn fill_physics_output(state: &PhysicsAirState) -> PhysicsAirOutput {
PhysicsAirOutput {
is_at_apex: state.reached_apex,
jump_height: state.max_y - state.start_y,
landing_normal: state.landing_normal,
scalar_184_write: state.selector_latch_174.then_some(OUTPUT_SCALAR_184),
}
}
fn initial_centre_of_mass_mode(frame: &PhysicsAirFrame) -> (bool, bool) {
if frame.previous_physics_category_2516 == 400 || frame.previous_physics_state_2504 == 701 {
(frame.flags_2468 & 0x0000_4000 == 0, false)
} else {
let selected = !matches!(frame.previous_physics_state_2504, 100 | 103 | 201);
(selected, selected)
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Independent arithmetic for original PhysicsAir's jump-velocity helpers.
//! Retains original refinements; PC results are not certified Xenon bit-exact.
use super::PhysicsAirMath;
use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic};
pub struct AirMath;
impl PhysicsAirMath for AirMath {
fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32 {
native_arithmetic::vector_min(left, right)
}
fn length_squared_vmsum3fp(&mut self, value: [f32; 4]) -> f32 {
native_arithmetic::dot3(value, value)
}
fn length_vmsum3fp_vrsqrte(&mut self, value: [f32; 4]) -> f32 {
let square = native_arithmetic::dot3(value, value);
let length = square * inverse_length_squared(square, 2);
if square == 0.0 { 0.0 } else { length }
}
fn clamp_vector_within_max_length(&mut self, value: [f32; 4], maximum: f32) -> [f32; 4] {
//82BD3D90..3E74, including the original short-vector bge and fsel.
let length = self.length_vmsum3fp_vrsqrte(value);
if !(length >= f32::from_bits(0x3780_0000)) { return value; }
let bound = if maximum - length >= -0.0 { length } else { maximum };
let mut inverse = native_arithmetic::reciprocal_estimate(length);
for _ in 0..2 {
inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse);
}
value.map(|v| (v * bound) * inverse)
}
}
///8296EBB0 uses one normalization refinement and a clamped dot product.
pub fn angle_between_vectors(left: [f32; 4], right: [f32; 4]) -> f32 {
let xyz = |v: [f32; 4]| crate::math::Vector3::new(v[0], v[1], v[2]);
crate::physics::board_ground::angle_between(xyz(left), xyz(right))
}
///Toolkit_ClampJumpVelocity82D93518. The output uses the CURRENT velocity's
///direction; the supplied reference only bounds its magnitude.
pub fn clamp_jump_velocity(reference: [f32; 4], current: [f32; 4]) -> [f32; 4] {
let reference_speed = AirMath.length_vmsum3fp_vrsqrte(reference);
let speed = AirMath.length_vmsum3fp_vrsqrte(current);
let denominator = if speed - 1.0 >= -0.0 { speed } else { 1.0 };
let scale = if denominator > reference_speed { reference_speed / denominator } else { 1.0 };
current.map(|v| v * scale)
}
+29
View File
@@ -0,0 +1,29 @@
//! Readable TU3 `PhysState_PhysicsAir` lifecycle and update composition.
//!
//! The state owns the recovered branch/order semantics. Calls into the board,
//! skeleton, trajectory selector, wipeout controller, and unresolved Xenon
//! vector math remain mandatory runtime interfaces with no fallback.
mod board;
mod data;
mod jump_velocity;
mod lifecycle;
mod math;
mod post_physics;
mod runtime;
mod update;
pub use board::update_skateboard;
pub use data::{
AirBoardForce, AirTrajectory, PhysicsAirFrame, PhysicsAirOutput, PhysicsAirReckoningFields,
PhysicsAirSettings, PhysicsAirState,
};
pub use jump_velocity::{PhysicsAirMath, calculate_velocity_from_jump};
pub use math::{AirMath, angle_between_vectors, clamp_jump_velocity};
pub use lifecycle::{enter, exit, fill_physics_output};
pub use post_physics::update_post_physics;
pub use runtime::{PhysicsAirLaunchInfo, PhysicsAirRuntime};
pub use update::{integrate_trajectory_fixed_step, update, wrap_signed_angle};
#[cfg(test)]
mod tests;
@@ -0,0 +1,15 @@
//! TU3 PhysicsAir post-physics update (`0x82D34DD8`).
use super::{data::PhysicsAirState, runtime::PhysicsAirRuntime};
/// Latches the first strict downward board velocity, then always runs the air
/// wipeout check. Negative zero and NaN do not mark the apex.
pub fn update_post_physics(state: &mut PhysicsAirState, runtime: &mut impl PhysicsAirRuntime) {
if !state.reached_apex {
let board_velocity = runtime.board_body_velocity();
if board_velocity[1] < 0.0 {
state.reached_apex = true;
}
}
runtime.check_for_air_wipeout(false);
}
@@ -0,0 +1,76 @@
//! Mandatory engine-facing effects used by TU3 PhysicsAir.
use super::{
data::{AirBoardForce, PhysicsAirReckoningFields, Vector4},
jump_velocity::PhysicsAirMath,
};
/// Mutations of the 272-byte trajectory launch record recovered at
/// `0x82D33448`, `0x82BE33D0`, and `0x82D34570`.
pub trait PhysicsAirLaunchInfo {
fn set_start_velocity(&mut self, velocity: Vector4);
fn centre_of_mass_animation_position(&self) -> Vector4;
fn set_trajectory_start_position_override(&mut self, position: Vector4);
fn set_board_position_override(&mut self, position: Vector4);
fn cone_angles(&self) -> [f32; 2];
fn set_cone_angles(&mut self, angles: [f32; 2]);
fn set_player_jumped(&mut self, jumped: bool);
fn set_use_trajectory_start_position_override(&mut self, enabled: bool);
fn set_trajectory_count(&mut self, count: u16);
}
/// Every external call/read made by the recovered PhysicsAir methods. There is
/// deliberately no no-op/default implementation: an engine adapter must bind
/// each stock-owned object before this state can be scheduled.
pub trait PhysicsAirRuntime: PhysicsAirMath {
type LaunchInfo: PhysicsAirLaunchInfo;
fn set_air_collision_update_enabled(&mut self, enabled: bool);
fn set_skeleton_collision_state(&mut self, state: u32);
fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool);
fn enable_board_angular_drive_only(&mut self);
fn set_footplant_flag_240(&mut self, value: bool);
fn reset_footplants(&mut self);
fn board_transform_height(&mut self) -> f32;
fn request_skeleton_heading_update(&mut self);
fn update_air_collision(&mut self);
fn trajectory_query_just_started(&self) -> bool;
fn construct_trajectory_launch_info(&mut self) -> Self::LaunchInfo;
fn fill_skeleton_launch_info(&mut self, info: &mut Self::LaunchInfo);
fn launch_trajectory(&mut self, info: &Self::LaunchInfo);
fn update_trajectory_selector(&mut self);
fn selector_landing_normal(&self) -> Option<Vector4>;
fn selector_centre_of_mass_trajectory_ready(&self) -> bool;
/// `AngleBetweenVectors` (`0x8296EBB0`), including its unresolved VMX
/// normalization behavior. The caller performs the recovered angle wrap.
fn angle_between_vectors(&mut self, left: Vector4, right: Vector4) -> f32;
///Return the completed canonical fields: UpdateSkateboard reads the newly
///filtered collision normal1216, after82D8DBD8, not the entry snapshot.
fn update_reckoning_air_states(
&mut self,
landing_normal: Vector4,
normal_blend: f32,
body_spin: f32,
body_flip: f32,
) -> PhysicsAirReckoningFields;
fn update_known_air_skeleton(&mut self, trajectory_position: Vector4);
fn update_animated_skateboard_skeleton(&mut self, argument: bool);
fn update_board_steering_tilt(&mut self, tilt: f32);
/// `Toolkit_CalcCollisionForce` (`0x82D944E8`). The adapter supplies its
/// bound settings and complete live ProcessedPhysIn; PhysicsAir only passes
/// the reference normal and receives the point-force record.
fn calculate_air_collision_force(
&mut self,
reference_normal: Vector4,
force: &mut AirBoardForce,
) -> bool;
fn enqueue_board_force(&mut self, tag: u32, force: AirBoardForce);
fn set_board_velocity(&mut self, velocity: Vector4);
fn enable_skateboard_error_on_skeleton(&mut self);
fn board_body_velocity(&mut self) -> Vector4;
fn check_for_air_wipeout(&mut self, argument: bool);
}
@@ -0,0 +1,551 @@
use super::*;
use crate::point_graph::PointGraph;
#[derive(Clone, Debug, Default, PartialEq)]
struct FakeLaunchInfo {
start_velocity: [f32; 4],
animation_position: [f32; 4],
trajectory_override: [f32; 4],
board_override: [f32; 4],
cone_angles: [f32; 2],
jumped_writes: Vec<bool>,
use_override: bool,
trajectory_count: u16,
}
impl PhysicsAirLaunchInfo for FakeLaunchInfo {
fn set_start_velocity(&mut self, velocity: [f32; 4]) {
self.start_velocity = velocity;
}
fn centre_of_mass_animation_position(&self) -> [f32; 4] {
self.animation_position
}
fn set_trajectory_start_position_override(&mut self, position: [f32; 4]) {
self.trajectory_override = position;
}
fn set_board_position_override(&mut self, position: [f32; 4]) {
self.board_override = position;
}
fn cone_angles(&self) -> [f32; 2] {
self.cone_angles
}
fn set_cone_angles(&mut self, angles: [f32; 2]) {
self.cone_angles = angles;
}
fn set_player_jumped(&mut self, jumped: bool) {
self.jumped_writes.push(jumped);
}
fn set_use_trajectory_start_position_override(&mut self, enabled: bool) {
self.use_override = enabled;
}
fn set_trajectory_count(&mut self, count: u16) {
self.trajectory_count = count;
}
}
#[derive(Debug)]
struct FakeRuntime {
events: Vec<String>,
board_height: f32,
board_velocity: [f32; 4],
query_started: bool,
selector_normal: Option<[f32; 4]>,
selector_com_ready: bool,
angle: f32,
collision: bool,
collision_force: AirBoardForce,
enqueued_force: Option<(u32, AirBoardForce)>,
set_velocity: Option<[f32; 4]>,
launched: Option<FakeLaunchInfo>,
reckoning_call: Option<([f32; 4], f32, f32, f32)>,
reckoning_output: PhysicsAirReckoningFields,
collision_reference: Option<[f32; 4]>,
known_air_position: Option<[f32; 4]>,
forced_squared_length: Option<f32>,
forced_length: Option<f32>,
forced_clamp: Option<[f32; 4]>,
}
impl Default for FakeRuntime {
fn default() -> Self {
Self {
events: Vec::new(),
board_height: 0.0,
board_velocity: [0.0; 4],
query_started: false,
selector_normal: None,
selector_com_ready: false,
angle: 0.0,
collision: false,
collision_force: AirBoardForce::ZERO,
enqueued_force: None,
set_velocity: None,
launched: None,
reckoning_call: None,
reckoning_output: reckoning(),
collision_reference: None,
known_air_position: None,
forced_squared_length: None,
forced_length: None,
forced_clamp: None,
}
}
}
impl PhysicsAirMath for FakeRuntime {
fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32 {
if left < right { left } else { right }
}
fn length_squared_vmsum3fp(&mut self, value: [f32; 4]) -> f32 {
self.forced_squared_length
.unwrap_or(value[0] * value[0] + value[1] * value[1] + value[2] * value[2])
}
fn length_vmsum3fp_vrsqrte(&mut self, value: [f32; 4]) -> f32 {
self.forced_length
.unwrap_or((value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt())
}
fn clamp_vector_within_max_length(&mut self, value: [f32; 4], maximum_length: f32) -> [f32; 4] {
if let Some(forced) = self.forced_clamp {
return forced;
}
let length = (value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt();
if length > maximum_length {
value.map(|lane| lane * (maximum_length / length))
} else {
value
}
}
}
impl PhysicsAirRuntime for FakeRuntime {
type LaunchInfo = FakeLaunchInfo;
fn set_air_collision_update_enabled(&mut self, enabled: bool) {
self.events.push(format!("collision-enabled:{enabled}"));
}
fn set_skeleton_collision_state(&mut self, state: u32) {
self.events.push(format!("collision-state:{state}"));
}
fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool) {
self.events.push(format!("ik:{enabled}"));
}
fn enable_board_angular_drive_only(&mut self) {
self.events.push("angular-drive".into());
}
fn set_footplant_flag_240(&mut self, value: bool) {
self.events.push(format!("footplant-240:{value}"));
}
fn reset_footplants(&mut self) {
self.events.push("footplant-reset".into());
}
fn board_transform_height(&mut self) -> f32 {
self.events.push("board-height".into());
self.board_height
}
fn request_skeleton_heading_update(&mut self) {
self.events.push("heading".into());
}
fn update_air_collision(&mut self) {
self.events.push("air-collision".into());
}
fn trajectory_query_just_started(&self) -> bool {
self.query_started
}
fn construct_trajectory_launch_info(&mut self) -> Self::LaunchInfo {
self.events.push("launch-construct".into());
FakeLaunchInfo::default()
}
fn fill_skeleton_launch_info(&mut self, info: &mut Self::LaunchInfo) {
self.events.push("launch-fill".into());
info.animation_position = [10.0, 20.0, 30.0, 40.0];
info.cone_angles = [2.0, 4.0];
}
fn launch_trajectory(&mut self, info: &Self::LaunchInfo) {
self.events.push("launch".into());
self.launched = Some(info.clone());
}
fn update_trajectory_selector(&mut self) {
self.events.push("selector-update".into());
}
fn selector_landing_normal(&self) -> Option<[f32; 4]> {
self.selector_normal
}
fn selector_centre_of_mass_trajectory_ready(&self) -> bool {
self.selector_com_ready
}
fn angle_between_vectors(&mut self, _left: [f32; 4], _right: [f32; 4]) -> f32 {
self.events.push("angle".into());
self.angle
}
fn update_reckoning_air_states(
&mut self,
landing_normal: [f32; 4],
normal_blend: f32,
body_spin: f32,
body_flip: f32,
) -> PhysicsAirReckoningFields {
self.events.push("reckoning".into());
self.reckoning_call = Some((landing_normal, normal_blend, body_spin, body_flip));
self.reckoning_output
}
fn update_known_air_skeleton(&mut self, trajectory_position: [f32; 4]) {
self.events.push("known-air".into());
self.known_air_position = Some(trajectory_position);
}
fn update_animated_skateboard_skeleton(&mut self, argument: bool) {
self.events.push(format!("animated-board:{argument}"));
}
fn update_board_steering_tilt(&mut self, tilt: f32) {
self.events.push(format!("steering-tilt:{tilt}"));
}
fn calculate_air_collision_force(
&mut self,
reference_normal: [f32; 4],
force: &mut AirBoardForce,
) -> bool {
self.events.push("collision-force".into());
self.collision_reference = Some(reference_normal);
*force = self.collision_force;
self.collision
}
fn enqueue_board_force(&mut self, tag: u32, force: AirBoardForce) {
self.events.push("enqueue-force".into());
self.enqueued_force = Some((tag, force));
}
fn set_board_velocity(&mut self, velocity: [f32; 4]) {
self.events.push("set-board-velocity".into());
self.set_velocity = Some(velocity);
}
fn enable_skateboard_error_on_skeleton(&mut self) {
self.events.push("board-error".into());
}
fn board_body_velocity(&mut self) -> [f32; 4] {
self.events.push("board-velocity".into());
self.board_velocity
}
fn check_for_air_wipeout(&mut self, argument: bool) {
self.events.push(format!("wipeout:{argument}"));
}
}
fn frame() -> PhysicsAirFrame {
PhysicsAirFrame {
current_velocity_400: [1.0, 2.0, 3.0, 0.0],
ground_position_y_500: 4.0,
trajectory_position_592: [1.0, 5.0, 2.0, 0.0],
trajectory_velocity_608: [2.0, 10.0, 4.0, 0.0],
jump_velocity_848: [2.0, 8.0, 4.0, 0.0],
flags_2468: 0,
previous_physics_state_2504: 500,
previous_physics_category_2516: 0,
frames_since_jump_correction_2576: 12,
delta_time_2604: 0.1,
body_spin_input_2640: 3.0,
gravity_y_2648: -10.0,
state_timer_2664: 1.0,
}
}
fn state() -> PhysicsAirState {
PhysicsAirState {
centre_of_mass_trajectory: AirTrajectory {
position: [90.0; 4],
velocity: [80.0; 4],
acceleration: [70.0; 4],
scalar_48: 60.0,
},
landing_normal: [9.0; 4],
time_in_state: 9.0,
start_y: 9.0,
max_y: 9.0,
reached_apex: true,
use_centre_of_mass_velocity: false,
selector_latch_174: true,
trajectory_query_countdown: 9,
}
}
fn reckoning() -> PhysicsAirReckoningFields {
PhysicsAirReckoningFields {
current_landing_normal_1152: [0.0, 1.0, 0.0, 0.0],
collision_reference_normal_1216: [0.0, 0.0, 1.0, 0.0],
body_spin_angle_1568: 2.0,
body_spin_speed_1572: 3.0,
}
}
fn settings() -> PhysicsAirSettings {
PhysicsAirSettings {
body_spin_over_time_320: PointGraph {
x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
y: [2.0; 8],
},
landing_normal_blend_388: 0.25,
body_spin_scale_428: 4.0,
landing_normal_angle_limit_444: 1.0,
}
}
#[test]
fn enter_preserves_order_and_offboard_velocity_rule() {
let mut state = state();
let frame = frame();
let mut runtime = FakeRuntime {
board_height: 6.0,
..Default::default()
};
enter(&mut state, &frame, [0.0, -9.0, 0.0, 0.0], &mut runtime);
assert_eq!(
runtime.events,
[
"collision-enabled:true",
"collision-state:7",
"ik:true",
"collision-state:6",
"angular-drive",
"footplant-240:false",
"footplant-reset",
"board-height",
"heading",
]
);
assert!(state.use_centre_of_mass_velocity);
assert_eq!(
state.centre_of_mass_trajectory.velocity,
[2.0, 3.0, 4.0, 0.0]
);
assert_eq!(state.landing_normal, [0.0, 1.0, 0.0, 0.0]);
assert_eq!((state.start_y, state.max_y), (4.0, 6.0));
assert!(!state.reached_apex && !state.selector_latch_174);
assert_eq!(state.trajectory_query_countdown, 0);
}
#[test]
fn category_400_can_select_com_without_requesting_heading() {
let mut state = state();
let mut frame = frame();
frame.previous_physics_category_2516 = 400;
frame.previous_physics_state_2504 = 100;
let mut runtime = FakeRuntime::default();
enter(&mut state, &frame, [0.0; 4], &mut runtime);
assert!(state.use_centre_of_mass_velocity);
assert!(!runtime.events.iter().any(|event| event == "heading"));
}
#[test]
fn jump_velocity_uses_current_vertical_and_clamped_current_horizontal() {
let mut frame = frame();
frame.current_velocity_400 = [10.0, 4.0, 0.0, 7.0];
frame.jump_velocity_848 = [2.0, 10.0, 0.0, 5.0];
let mut runtime = FakeRuntime {
forced_squared_length: Some(64.0),
forced_length: Some(2.0),
forced_clamp: Some([2.0, 0.0, 0.0, 99.0]),
..Default::default()
};
let result = calculate_velocity_from_jump(&frame, 2, &mut runtime);
assert_eq!(result, [2.0, 4.0, 0.0, 5.0]);
}
#[test]
fn update_runs_com_launch_and_fixed_step_in_native_order() {
let mut state = state();
state.use_centre_of_mass_velocity = true;
state.selector_latch_174 = false;
state.trajectory_query_countdown = 0;
state.time_in_state = 0.25;
state.max_y = 5.0;
state.centre_of_mass_trajectory = AirTrajectory {
position: [1.0, 2.0, 3.0, 0.0],
velocity: [4.0, 5.0, 6.0, 0.0],
acceleration: [0.0, -6.0, 0.0, 0.0],
scalar_48: -1.0,
};
let mut frame = frame();
frame.frames_since_jump_correction_2576 = 12;
let mut runtime = FakeRuntime {
board_height: 7.0,
selector_normal: Some([0.0, 0.8, 0.2, 0.0]),
reckoning_output: PhysicsAirReckoningFields {
collision_reference_normal_1216: [1.0, 0.0, 0.0, 0.0],
..reckoning()
},
..Default::default()
};
update(
&mut state,
&frame,
&settings(),
&mut reckoning(),
[0.0, -6.0, 0.0, 0.0],
&mut runtime,
);
assert_eq!(
runtime.events,
[
"air-collision",
"launch-construct",
"launch-fill",
"launch-fill",
"launch",
"selector-update",
"angle",
"reckoning",
"known-air",
"steering-tilt:0",
"collision-force",
"board-error",
"board-height",
]
);
let launch = runtime.launched.unwrap();
assert_eq!(launch.jumped_writes, [true, false]);
assert_eq!(launch.trajectory_override, [10.0, 19.35, 30.0, 40.0]);
assert_eq!(launch.board_override, [10.0, 19.2, 30.0, 40.0]);
assert_eq!(launch.cone_angles, [0.8, 1.6]);
assert!(launch.use_override);
assert_eq!(launch.trajectory_count, 5);
assert_eq!(state.trajectory_query_countdown, 7);
assert_eq!(state.max_y, 7.0);
assert_eq!(state.time_in_state, 0.35);
assert_eq!(
runtime.collision_reference,
Some([1.0, 0.0, 0.0, 0.0]),
"Board collision used the pre-update reckoning normal"
);
}
#[test]
fn skateboard_collision_force_wins_over_direct_velocity_correction() {
let state = state();
let force = AirBoardForce {
force_world: [1.0, 2.0, 3.0, 4.0],
point_board_local: [5.0, 6.0, 7.0, 8.0],
};
let mut runtime = FakeRuntime {
collision: true,
collision_force: force,
..Default::default()
};
let mut frame = frame();
frame.frames_since_jump_correction_2576 = 0;
update_skateboard(&state, &frame, &reckoning(), &mut runtime);
assert_eq!(runtime.enqueued_force, Some((15, force)));
assert_eq!(runtime.set_velocity, None);
}
#[test]
fn skateboard_miss_applies_jump_velocity_only_before_frame_twelve() {
let mut state = state();
state.use_centre_of_mass_velocity = false;
let mut frame = frame();
frame.frames_since_jump_correction_2576 = 2;
frame.current_velocity_400 = [2.0, 4.0, 4.0, 0.0];
frame.jump_velocity_848 = [2.0, 10.0, 4.0, 0.0];
let mut runtime = FakeRuntime {
forced_squared_length: Some(0.0),
..Default::default()
};
update_skateboard(&state, &frame, &reckoning(), &mut runtime);
assert_eq!(runtime.set_velocity, Some([2.0, 4.0, 4.0, 0.0]));
assert_eq!(
runtime.events,
["steering-tilt:0", "collision-force", "set-board-velocity"]
);
}
#[test]
fn post_physics_latches_only_strict_downward_velocity_then_checks_wipeout() {
let mut state = state();
state.reached_apex = false;
let mut runtime = FakeRuntime {
board_velocity: [0.0, -0.0, 0.0, 0.0],
..Default::default()
};
update_post_physics(&mut state, &mut runtime);
assert!(!state.reached_apex);
runtime.board_velocity[1] = -0.01;
update_post_physics(&mut state, &mut runtime);
assert!(state.reached_apex);
assert_eq!(
runtime.events,
[
"board-velocity",
"wipeout:false",
"board-velocity",
"wipeout:false"
]
);
}
#[test]
fn exit_and_output_publish_only_recovered_fields() {
let mut state = state();
state.max_y = 9.0;
state.start_y = 4.0;
state.reached_apex = true;
state.selector_latch_174 = true;
state.landing_normal = [1.0, 2.0, 3.0, 4.0];
let mut reckoning = reckoning();
let output = fill_physics_output(&state);
assert_eq!(output.jump_height, 5.0);
assert_eq!(output.scalar_184_write.map(f32::to_bits), Some(0x4080_0000));
assert_eq!(output.landing_normal, [1.0, 2.0, 3.0, 4.0]);
exit(&mut state, &mut reckoning);
assert!(!state.use_centre_of_mass_velocity);
assert_eq!(reckoning.body_spin_angle_1568, 0.0);
assert_eq!(reckoning.body_spin_speed_1572, 0.0);
}
#[test]
fn angle_wrap_uses_recovered_turn_constants() {
assert_eq!(wrap_signed_angle(0.0).to_bits(), 0.0_f32.to_bits());
assert!(wrap_signed_angle(4.0) < 0.0);
assert!(wrap_signed_angle(2.0) > 0.0);
}
+193
View File
@@ -0,0 +1,193 @@
//! TU3 PhysicsAir Update (`0x82D346C0`) and COM launch helper (`0x82D34570`).
use crate::point_graph::PointGraph;
use super::{
board::update_skateboard,
data::{
AirTrajectory, PhysicsAirFrame, PhysicsAirReckoningFields, PhysicsAirSettings,
PhysicsAirState, Vector4,
},
jump_velocity::calculate_velocity_from_jump,
runtime::{PhysicsAirLaunchInfo, PhysicsAirRuntime},
};
const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 1/60, 0x82098D40
const HALF: f32 = f32::from_bits(0x3F00_0000);
const ONE: f32 = f32::from_bits(0x3F80_0000);
const TRAJECTORY_UNBOUNDED: f32 = f32::from_bits(0xBF80_0000);
const TWO: f32 = f32::from_bits(0x4000_0000);
const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35);
const TWO_PI: f32 = f32::from_bits(0x40C9_0FDB);
const RECIPROCAL_TWO_PI: f32 = f32::from_bits(0x3E22_F983);
const HALF_TURN: f32 = f32::from_bits(0x3F00_0000);
const COM_START_Y_OFFSET: f32 = f32::from_bits(0xBF26_6666); // -0.65
const BOARD_START_Y_OFFSET: f32 = f32::from_bits(0xBF4C_CCCD); // -0.8
const CONE_SCALE: f32 = f32::from_bits(0x3ECC_CCCD); // 0.4
/// Full recovered outer update, retaining native effect order.
pub fn update(
state: &mut PhysicsAirState,
frame: &PhysicsAirFrame,
settings: &PhysicsAirSettings,
reckoning: &mut PhysicsAirReckoningFields,
world_acceleration: Vector4,
runtime: &mut impl PhysicsAirRuntime,
) {
runtime.update_air_collision();
state.trajectory_query_countdown = state.trajectory_query_countdown.wrapping_sub(1);
if state.trajectory_query_countdown <= 0
&& frame.flags_2468 & 0x0000_8000 == 0
&& frame.previous_physics_state_2504 != 0
&& !runtime.trajectory_query_just_started()
{
launch_trajectory(state, frame, world_acceleration, runtime);
state.trajectory_query_countdown = 7;
}
runtime.update_trajectory_selector();
let current_normal = reckoning.current_landing_normal_1152;
let landing_normal = runtime.selector_landing_normal().unwrap_or(current_normal);
state.landing_normal = landing_normal;
let raw_angle = runtime.angle_between_vectors(landing_normal, current_normal);
let wrapped_angle = wrap_signed_angle(raw_angle);
let mut normal_blend = if wrapped_angle <= settings.landing_normal_angle_limit_444 {
settings.landing_normal_blend_388
} else {
0.0
};
if !state.use_centre_of_mass_velocity {
if !state.selector_latch_174 {
state.selector_latch_174 = runtime.selector_centre_of_mass_trajectory_ready();
}
if state.selector_latch_174 {
state.use_centre_of_mass_velocity = true;
state.centre_of_mass_trajectory = AirTrajectory {
position: frame.trajectory_position_592,
velocity: frame.trajectory_velocity_608,
acceleration: world_acceleration,
scalar_48: TRAJECTORY_UNBOUNDED,
};
}
}
if frame.state_timer_2664 <= 0.0 {
normal_blend = 0.0;
} else if frame.flags_2468 & 0x0000_4000 != 0 {
state.use_centre_of_mass_velocity = false;
}
let scaled_spin_input = frame.body_spin_input_2640 * settings.body_spin_scale_428;
let spin_radians = scaled_spin_input * DEGREES_TO_RADIANS;
let curve = evaluate_spin_curve(&settings.body_spin_over_time_320, state.time_in_state * TWO);
let target_body_spin = curve * spin_radians;
*reckoning =
runtime.update_reckoning_air_states(landing_normal, normal_blend, target_body_spin, 0.0);
if state.use_centre_of_mass_velocity {
integrate_trajectory_fixed_step(&mut state.centre_of_mass_trajectory);
runtime.update_known_air_skeleton(state.centre_of_mass_trajectory.position);
} else {
runtime.update_animated_skateboard_skeleton(false);
}
update_skateboard(state, frame, reckoning, runtime);
runtime.enable_skateboard_error_on_skeleton();
let board_y = runtime.board_transform_height();
// fcmpu+bgt keeps the old value only for an ordered old > new result;
// unordered values take the board-height write.
if !(state.max_y > board_y) {
state.max_y = board_y;
}
state.time_in_state += frame.delta_time_2604;
}
/// Scalar wrap at `0x82D347E8..0x82D34848`, after AngleBetweenVectors.
pub fn wrap_signed_angle(angle: f32) -> f32 {
let turns = angle * RECIPROCAL_TWO_PI;
let fraction = turns - turns.floor();
let signed_fraction = if fraction > HALF_TURN {
fraction - ONE
} else {
fraction
};
signed_fraction * TWO_PI
}
/// Fixed 1/60 ballistic step at `0x82D349B8..0x82D34A20`.
pub fn integrate_trajectory_fixed_step(trajectory: &mut AirTrajectory) {
let step_squared = FIXED_STEP * FIXED_STEP;
let linear_position: Vector4 = core::array::from_fn(|lane| {
trajectory.velocity[lane].mul_add(FIXED_STEP, trajectory.position[lane])
});
let next_velocity: Vector4 = core::array::from_fn(|lane| {
trajectory.acceleration[lane].mul_add(FIXED_STEP, trajectory.velocity[lane])
});
let half = ONE * HALF;
let half_acceleration = trajectory.acceleration.map(|lane| lane * half);
let next_position: Vector4 = core::array::from_fn(|lane| {
half_acceleration[lane].mul_add(step_squared, linear_position[lane])
});
trajectory.position = next_position;
trajectory.velocity = next_velocity;
}
fn launch_trajectory(
state: &PhysicsAirState,
frame: &PhysicsAirFrame,
world_acceleration: Vector4,
runtime: &mut impl PhysicsAirRuntime,
) {
let mut info = runtime.construct_trajectory_launch_info();
runtime.fill_skeleton_launch_info(&mut info);
if frame.frames_since_jump_correction_2576 < 12 {
let velocity =
calculate_velocity_from_jump(frame, frame.frames_since_jump_correction_2576, runtime);
info.set_start_velocity(velocity);
} else if state.use_centre_of_mass_velocity {
adjust_centre_of_mass_launch_info(state, world_acceleration, runtime, &mut info);
}
// The caller always clears +268, including after the COM helper sets it.
info.set_player_jumped(false);
runtime.launch_trajectory(&info);
}
fn adjust_centre_of_mass_launch_info<R: PhysicsAirRuntime>(
state: &PhysicsAirState,
world_acceleration: Vector4,
runtime: &mut R,
info: &mut R::LaunchInfo,
) {
runtime.fill_skeleton_launch_info(info);
let velocity = core::array::from_fn(|lane| {
world_acceleration[lane].mul_add(FIXED_STEP, state.centre_of_mass_trajectory.velocity[lane])
});
info.set_start_velocity(velocity);
info.set_player_jumped(true);
let animation_position = info.centre_of_mass_animation_position();
let trajectory_offset = [0.0, COM_START_Y_OFFSET, 0.0, 0.0];
let board_offset = [0.0, BOARD_START_Y_OFFSET, 0.0, 0.0];
info.set_trajectory_start_position_override(core::array::from_fn(|lane| {
animation_position[lane] + trajectory_offset[lane]
}));
info.set_board_position_override(core::array::from_fn(|lane| {
animation_position[lane] + board_offset[lane]
}));
info.set_use_trajectory_start_position_override(true);
info.set_trajectory_count(5);
let cone_angles = info.cone_angles();
info.set_cone_angles([cone_angles[0] * CONE_SCALE, cone_angles[1] * CONE_SCALE]);
}
fn evaluate_spin_curve(graph: &PointGraph<8>, input: f32) -> f32 {
graph.evaluate(input)
}
@@ -0,0 +1,170 @@
//! Original TU3 trajectory launch calculations82D67D08/82D67320/82D682E8.
use super::{LaunchInfo, QueryRequest, SelectorInput, SelectorSettings, Trajectory, math::*};
pub(super) fn adjust_velocity(
info: &mut LaunchInfo,
input: SelectorInput,
s: &SelectorSettings,
) -> bool {
let n = input.ground_normal;
let v = info.start_velocity;
let mut normal = scale(n, dot(v, n));
normal[1] = 0.0;
let combined = add([0.0, v[1], 0.0, 0.0], normal);
let residual = sub(v, combined);
let lean = (input.directional_input - 0.25).max(-1.0).min(1.0);
let aligned = normalize(sub(UP, scale(n, lean * s.vert_jump_align_max_angle)));
if n[1] >= s.vert_jump_align_max_ground_normal_y {
return false;
}
if normalize(combined)[1] > s.vert_jump_align_min_direction_y {
//The second term really uses UNIT velocity, not the original speed.
let left = scale(
add(scale(aligned, length(combined)), residual),
s.vert_jump_align_factor,
);
let right = scale(normalize(v), 1.0 - s.vert_jump_align_factor);
info.start_velocity = scale(normalize(add(left, right)), length(v));
true
} else {
if !info.player_jumped && input.previous_physics_state != 200 {
let normal = scale(n, dot(v, n));
info.start_velocity = add(
scale(normal, s.natural_air_off_verts_scalar),
sub(v, normal),
);
}
false
}
}
pub(super) fn candidate_velocities(info: LaunchInfo, s: &SelectorSettings) -> Vec<Vector> {
let count = usize::from(info.trajectory_count.min(7));
let mut velocities = Vec::with_capacity(count);
if count == 0 {
return velocities;
}
let v = info.start_velocity;
velocities.push(v);
let right = normalize(cross(UP, v));
let forward = normalize(cross(right, UP));
let speed = length(v);
let cone_speed = if info.player_jumped {
length(info.com_velocity)
} else {
speed
};
let radians = f32::from_bits(0x3c8e_fa35);
let x = info.cone_angle_x * radians;
let z = (s.cone_angle_z_vs_speed.evaluate(cone_speed * 0.05) * info.cone_angle_z) * radians;
let cone_speed = cone_speed.max(s.speed_factor_min).min(s.speed_factor_max);
let right = scale(right, crate::trigonometry::sin(x) * cone_speed);
let forward = scale(forward, crate::trigonometry::sin(z) * cone_speed);
if count > 1 {
let step = f32::from_bits(0x40c9_0fdb) / (count - 1) as f32;
let mut angle = 0.0;
for _ in 1..count {
let candidate = madd(
forward,
crate::trigonometry::cos(angle),
madd(right, crate::trigonometry::sin(angle), v),
);
velocities.push(scale(normalize(candidate), length(candidate).min(speed)));
angle += step;
}
}
velocities
}
#[derive(Clone, Debug)]
pub(super) struct LaunchBatch {
pub requests: Vec<QueryRequest>,
pub velocities: Vec<Vector>,
pub origin: Vector, //2864
pub board_position: Vector, //2848
pub local_board_position: Vector, //2832
pub local_com_position: Vector, //2784
pub com_displacement: Vector, //2768
}
pub(super) fn batch(info: LaunchInfo, input: SelectorInput, s: &SelectorSettings) -> LaunchBatch {
let velocities = candidate_velocities(info, s);
let ground = s
.displacement_vs_ground_normal
.evaluate(input.ground_normal[1].abs());
let speed = s
.displacement_vs_speed
.evaluate(input.board_vertical_velocity * 0.1);
let blend = speed.max(ground);
let height = dot(
sub(info.animation_com_position, input.contact_position),
info.reckoning_transform[1],
) - s.trajectory_radius;
let displacement =
height.min(s.trajectory_displacement) * (1.0 - blend) + s.trajectory_displacement * blend;
let mut origin = sub(
info.animation_com_position,
scale(info.reckoning_transform[1], displacement),
);
let mut board_position = info.board_position;
if info.use_position_override {
origin = info.start_position_override;
board_position = info.board_position_override;
}
let com_displacement = sub(info.animation_com_position, origin);
let duration = max_time(info.start_velocity, s);
let requests = velocities
.iter()
.map(|&velocity| QueryRequest {
trajectory: Trajectory {
position: madd(velocity, info.timestep, origin),
velocity,
acceleration: input.gravity,
duration,
},
radius: s.trajectory_radius,
start_error: s.trajectory_error_start,
end_error: s.trajectory_error_end,
})
.collect();
LaunchBatch {
requests,
velocities,
origin,
board_position,
com_displacement,
local_board_position: transform(info.reckoning_inverse, sub(info.board_position, origin)),
local_com_position: transform(info.reckoning_inverse, com_displacement),
}
}
///82D68698 deliberately uses the original -19.6 and -1/9.8 coefficients.
fn max_time(velocity: Vector, s: &SelectorSettings) -> f32 {
let square = velocity[1].mul_add(
velocity[1],
-(s.trajectory_max_drop * f32::from_bits(0xc19c_cccd)),
);
let root = if square == 0.0 {
0.0
} else {
square * inverse_length(square)
};
((-velocity[1] - root) * f32::from_bits(0xbdd0_fac6)).min(s.trajectory_max_time)
}
///tTrajectory::AdjustTrajectory82D609E0 changes velocity only.
pub(super) fn adjust_trajectory(
trajectory: &mut Trajectory,
frame: i32,
adjustment: Vector,
maximum: f32,
) {
if frame <= 0 {
return;
}
let correction = scale(adjustment, reciprocal(frame as f32 * STEP));
let scalar = if dot(correction, correction) > maximum * maximum {
maximum / length(correction)
} else {
1.0
};
trajectory.velocity = madd(correction, scalar, trajectory.velocity);
}
@@ -0,0 +1,80 @@
//! Independent PC arithmetic retaining the recovered refinement sequences.
use crate::physics::native_arithmetic;
pub(super) type Vector = [f32; 4];
pub(super) type Transform = [Vector; 4];
pub(super) const ZERO: Vector = [0.0; 4];
pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0];
pub(super) const STEP: f32 = f32::from_bits(0x3c88_8889);
pub(super) const IDENTITY: Transform = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO];
pub(super) fn dot(a: Vector, b: Vector) -> f32 {
native_arithmetic::dot3(a, b)
}
pub(super) fn add(a: Vector, b: Vector) -> Vector {
core::array::from_fn(|i| a[i] + b[i])
}
pub(super) fn sub(a: Vector, b: Vector) -> Vector {
core::array::from_fn(|i| a[i] - b[i])
}
pub(super) fn scale(a: Vector, s: f32) -> Vector {
a.map(|v| v * s)
}
pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector {
core::array::from_fn(|i| a[i].mul_add(s, b[i]))
}
pub(super) fn cross(a: Vector, b: Vector) -> Vector {
[
(-a[2]).mul_add(b[1], a[1] * b[2]),
(-a[0]).mul_add(b[2], a[2] * b[0]),
(-a[1]).mul_add(b[0], a[0] * b[1]),
0.0,
]
}
pub(super) fn inverse_length(square: f32) -> f32 {
let mut v = native_arithmetic::reciprocal_square_root_estimate(square);
for _ in 0..2 {
v = (v * 0.5).mul_add((-square).mul_add(v * v, 1.0), v);
}
v
}
pub(super) fn reciprocal(value: f32) -> f32 {
let mut inverse = native_arithmetic::reciprocal_estimate(value);
for _ in 0..2 {
inverse = inverse.mul_add((-inverse).mul_add(value, 1.0), inverse);
}
inverse
}
///8296EBB0: squared-length gate, ONE refinement, clamped dot, native acos.
pub(super) fn angle_between(a: Vector, b: Vector) -> f32 {
let aa = dot(a, a);
let bb = dot(b, b);
if !(aa > f32::from_bits(0x38d1_b717) && bb > f32::from_bits(0x38d1_b717)) {
return 0.0;
}
let unit = |v: Vector, square: f32| {
let r = native_arithmetic::reciprocal_square_root_estimate(square);
scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.0), r))
};
crate::trigonometry::acos(dot(unit(a, aa), unit(b, bb)).max(-1.0).min(1.0))
}
pub(super) fn length(a: Vector) -> f32 {
let square = dot(a, a);
if square == 0.0 {
0.0
} else {
square * inverse_length(square)
}
}
pub(super) fn normalize(a: Vector) -> Vector {
let square = dot(a, a);
let inverse = inverse_length(square);
let length = if square == 0.0 { 0.0 } else { square * inverse };
//830BD350's initializer82F826F8 broadcasts82181A88 (1e-6).
if length > f32::from_bits(0x3586_37bd) {
scale(a, inverse)
} else {
ZERO
}
}
pub(super) fn transform(m: Transform, a: Vector) -> Vector {
madd(m[2], a[2], madd(m[1], a[1], madd(m[0], a[0], m[3])))
}
@@ -0,0 +1,13 @@
//! TU3 trajectory selection82D67848/82D682E8/82D68800.
//! Host world storage and batch ownership are independent of console layout.
mod launch;
mod math;
mod prediction;
mod query;
mod scoring;
mod selector;
mod types;
pub use prediction::{Prediction, QueryRequest, QueryResult, Trajectory};
pub use query::{SurfaceHit, query_trajectory};
pub use selector::{Selection, TrajectorySelector, WorldWithoutGrindEdges};
pub use types::{LaunchInfo, SelectorInput, SelectorSettings};
@@ -0,0 +1,90 @@
//! tTrajectory and complete PredictionResults8276C9D8/8276DBE0.
use super::math::{IDENTITY, STEP, Transform, UP, Vector, ZERO, reciprocal};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Trajectory {
pub position: Vector, //0 metres
pub velocity: Vector, //16 metres/second
pub acceleration: Vector, //32 metres/second squared
pub duration: f32, //48 seconds; -1 means unbounded
}
impl Trajectory {
pub fn position_at(self, time: f32) -> Vector {
let square = time * time;
core::array::from_fn(|i| {
(self.acceleration[i] * 0.5)
.mul_add(square, self.velocity[i].mul_add(time, self.position[i]))
})
}
pub fn velocity_at(self, time: f32) -> Vector {
core::array::from_fn(|i| self.acceleration[i].mul_add(time, self.velocity[i]))
}
pub fn highest_position(self) -> (Vector, f32) {
//82D2CCA0: downward velocity or nonnegative gravity has no apex.
if self.velocity[1] < 0.0 || self.acceleration[1] >= 0.0 {
(self.position, 0.0)
} else {
let t = self.velocity[1] * reciprocal(-self.acceleration[1]);
(self.position_at(t), t)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct QueryRequest {
pub trajectory: Trajectory, //request144..207
pub radius: f32, //208 metres
pub start_error: f32, //212 multiplier of radius
pub end_error: f32, //216 multiplier of radius
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct QueryResult {
pub contact_position: Vector, //0
pub contact_normal: Vector, //16 first hit face normal
pub landing_normal: Vector, //32 velocity-qualified nearby-face average
pub contact_time: f32, //48 seconds; -1 is no contact
pub contact_transform: Transform, //64 static world identity
pub contact_frame: i32, //128
pub surface: u32, //132 original surface data
pub geometry: u32, //136 host geometry identity
}
impl QueryResult {
pub const fn miss() -> Self {
Self {
contact_position: ZERO,
contact_normal: UP,
landing_normal: UP,
contact_time: -1.0,
contact_transform: IDENTITY,
contact_frame: -1,
surface: 0,
geometry: 0,
}
}
pub fn valid(self) -> bool {
self.contact_time >= 0.0
}
///PredictionResults::GetLandingNormal82D2D9E8.
pub fn suggested_normal(self) -> Vector {
if self.valid() {
self.landing_normal
} else {
UP
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Prediction {
pub result: QueryResult,
pub request: QueryRequest,
}
impl Prediction {
pub fn collision_position(self) -> Vector {
self.request
.trajectory
.position_at(self.result.contact_time)
}
pub fn collision_velocity(self) -> Vector {
self.request
.trajectory
.velocity_at(self.result.contact_frame as f32 * STEP)
}
}
@@ -0,0 +1,152 @@
//! Complete trajectory-segment walk82770910 and result writer8276DBE0.
//! The host supplies real nearest swept-line hits and intersecting triangles.
use super::{QueryRequest, QueryResult, math::*};
#[derive(Clone, Copy, Debug)]
pub struct SurfaceHit {
pub position: Vector,
pub normal: Vector,
pub transform: Transform,
pub surface: u32,
pub geometry: u32,
}
pub fn query_trajectory<E>(
request: QueryRequest,
mut line: impl FnMut(Vector, Vector, f32) -> Result<Option<SurfaceHit>, E>,
mut nearby: impl FnMut(Vector, f32) -> Result<Vec<[Vector; 3]>, E>,
) -> Result<QueryResult, E> {
let trajectory = request.trajectory;
let mut start_step = trajectory.duration;
let mut end_step = trajectory.duration;
if trajectory.acceleration[1] < 0.0 {
start_step = step_size(
trajectory.acceleration[1],
request.start_error * request.radius,
);
end_step = step_size(
trajectory.acceleration[1],
request.end_error * request.radius,
);
}
let minimum_square =
((request.start_error * request.radius) * request.start_error) * request.radius;
let mut time = 0.0;
let mut start = trajectory.position_at(time);
let mut end = trajectory.position_at(time + start_step);
while trajectory.duration > time {
let delta = sub(end, start);
if dot(delta, delta) > minimum_square {
//82770B40 rejects a segment only when all XYZ are tiny.
if delta[..3]
.iter()
.any(|v| v.abs() > f32::from_bits(0x3780_0000))
{
if let Some(hit) = line(start, end, request.radius)? {
let (contact_time, contact_frame) =
time_at_contact(request, hit.position, time);
let triangles = nearby(hit.position, request.radius)?;
let velocity = trajectory.velocity_at(contact_frame as f32 * STEP);
return Ok(QueryResult {
contact_position: hit.position,
contact_normal: hit.normal,
landing_normal: average_landing_normal(&triangles, velocity),
contact_time,
contact_frame,
contact_transform: hit.transform,
surface: hit.surface,
geometry: hit.geometry,
});
}
}
start = end;
}
let fraction = reciprocal(trajectory.duration) * time;
let step = end_step.mul_add(fraction, (1.0 - fraction) * start_step);
time += step;
//The native walk evaluates beyond the horizon; it does not clamp end.
end = trajectory.position_at(time + step);
}
Ok(QueryResult::miss())
}
fn reciprocal(value: f32) -> f32 {
let mut inverse = crate::physics::native_arithmetic::reciprocal_estimate(value);
for _ in 0..2 {
inverse = inverse.mul_add((-inverse).mul_add(value, 1.0), inverse);
}
inverse
}
fn step_size(gravity_y: f32, error_radius: f32) -> f32 {
let square = reciprocal(gravity_y) * (-8.0 * error_radius);
if square == 0.0 {
0.0
} else {
square * inverse_length(square)
}
}
///8276C558 writes both collision seconds and integer frame, including its
///distinct negligible-gravity branch. A miss is handled by the caller.
fn time_at_contact(request: QueryRequest, position: Vector, mut time: f32) -> (f32, i32) {
const EPSILON: f32 = f32::from_bits(0x38d1_b717);
let trajectory = request.trajectory;
if EPSILON >= (reciprocal(2.0) * trajectory.acceleration[1]).abs() {
let distance = length(sub(position, trajectory.position));
if !(distance.abs() > EPSILON) {
return (0.0, 0);
}
let speed = length(trajectory.velocity);
if !(speed.abs() > EPSILON) {
return (0.0, 0);
}
let frames = reciprocal(speed) * distance;
return (frames * STEP, frames.ceil() as i32);
}
let mut closest_square = f32::MAX;
let mut found = false;
while trajectory.duration >= time {
let delta = sub(position, trajectory.position_at(time));
let square = dot(delta, delta);
if closest_square >= square {
closest_square = square;
} else if square > closest_square {
time -= STEP;
found = true;
break;
}
time += STEP;
}
if !found {
time = trajectory.duration;
}
//82F4E948 is ceiling (truncate then add1 for a positive remainder).
(time, (reciprocal(STEP) * time).ceil() as i32)
}
///82771018, using at most64 triangles retained by82772028 in world order.
fn average_landing_normal(triangles: &[[Vector; 3]], velocity: Vector) -> Vector {
let mut accepted = Vec::with_capacity(64);
let mut most_up = ZERO;
let mut highest = -1.0;
for triangle in triangles.iter().take(64) {
let normal = cross(sub(triangle[1], triangle[0]), sub(triangle[2], triangle[0]));
//This first stage uses Normalize, not NormalizeSafe.
let normal = scale(normal, inverse_length(dot(normal, normal)));
if normal[1] > 0.7 || dot(normal, velocity) <= 0.0 {
let up = dot(normal, UP);
if up > highest {
highest = up;
most_up = normal;
}
accepted.push(normal);
}
}
if accepted.is_empty() {
return UP;
}
let mut total = ZERO;
for normal in accepted {
if dot(most_up, normal) > 0.5 {
total = add(total, normal);
}
}
normalize(total)
}
@@ -0,0 +1,193 @@
//! TU3 scoring82D68FE8/82D68AF0/82D69790 for a world with no grind edges.
//! That topology is supplied by the authored BoardWorld, not inferred from a
//! failed collision. Every wall probe still queries real world geometry.
use super::{
Prediction, SelectorInput, SelectorSettings, SurfaceHit, launch::adjust_trajectory, math::*,
};
#[derive(Clone, Copy, Debug)]
pub(super) struct Candidate {
pub prediction: Prediction,
pub start_velocity: Vector, //candidate64+0, before wall adjustment
pub normal: Vector, //+16
pub collision_velocity: Vector, //+32, before wall adjustment
pub collision_position: Vector, //+48, before wall adjustment
pub score: f32,
pub wall_score: f32,
pub wall_ride: bool,
}
pub(super) fn score(
candidates: &mut [Candidate],
pass: u16,
adjusted_on_vert: bool,
input: SelectorInput,
s: &SelectorSettings,
mut line: impl FnMut(Vector, Vector, f32) -> Result<Option<SurfaceHit>, String>,
) -> Result<bool, String> {
let mut all_miss = true; //selector9662, consumed by PhysicsAir COM latch
let mut highest_wall = -1_000_000.0;
for (index, c) in candidates.iter_mut().enumerate() {
c.score = 0.0;
c.wall_score = 0.0;
c.wall_ride = false;
if !c.prediction.result.valid() {
continue;
}
all_miss = false;
if c.prediction.result.contact_frame < s.minimum_trajectory_frames {
continue;
}
let middle = index == 0 && pass == 1;
let middle_bonus = if middle && !adjusted_on_vert {
s.score_middle_bonus
} else {
0.0
};
c.collision_position = c.prediction.collision_position();
c.normal = c.prediction.result.suggested_normal();
c.prediction.result.contact_normal = c.normal;
c.collision_velocity = c.prediction.collision_velocity();
let force_dot = dot(c.normal, c.collision_velocity);
let sideways = length(cross(c.normal, normalize(c.collision_velocity)));
let scalar = s.landing_force_scalar.evaluate(force_dot.abs());
let force_score = (force_dot * s.score_landing_force) * scalar;
let direction_score = (sideways * s.score_landing_direction) * scalar;
//82D69C00 returns0 immediately for the actual empty edge query. Its
//9552 flag remains clear.82D69D68 then evaluates the stock graph at
//its native invalid-query distance1000; do not substitute a score.
let grind_score = if input.flags_2476 & 0x0200_0000 == 0
&& (input.grind_lock_distance > 0.5 || !middle)
{
s.grind_penalty_vs_distance.evaluate(1000.0)
} else {
0.0
};
if grind_score <= 0.0 {
c.wall_score = wall_score(
&mut c.prediction,
grind_score < 0.0,
s,
&mut highest_wall,
&mut line,
)?;
c.wall_ride = c.wall_score > 0.0;
if c.wall_score < 0.0 {
c.prediction.result.contact_normal = UP;
}
}
let mut surface_score = 0.0;
if grind_score <= 1.0 {
match (c.prediction.result.surface >> 7) & 31 {
6 => {
surface_score = s.surface_unrideable_score;
c.prediction.result.contact_normal = input.reference_up;
}
7 => {
surface_score = s.surface_dont_align_score;
c.prediction.result.contact_normal = input.reference_up;
}
8 => {
surface_score = s.surface_dont_align_score;
}
_ => {}
}
}
c.normal = c.prediction.result.contact_normal;
let mut transition = 0.0;
if adjusted_on_vert {
let sign = if input.directional_input >= 0.5 {
1.0
} else {
-1.0
};
let mut heading = input.heading_direction;
heading[1] = 0.0;
let since_apex = (c.prediction.result.contact_time - apex_time(c.prediction)).abs();
let penalty = if since_apex >= s.minimum_time_after_apex {
0.0
} else {
-500.0
};
let coefficient = ((1.0 - input.ground_normal[1].abs()) * sign) * s.score_transition;
transition = dot(c.normal, normalize(heading)) * coefficient + penalty;
}
c.score = s
.landing_time_bonus
.evaluate(c.prediction.result.contact_time)
+ (((((direction_score + 1.0) + force_score) + middle_bonus) + grind_score)
+ transition);
c.score += surface_score;
}
//AddWallRideScores runs after ALL candidates, using the final highest Y.
for c in candidates {
if c.prediction.result.valid()
&& c.prediction.result.contact_frame >= s.minimum_trajectory_frames
{
if c.wall_score == 0.0 && c.prediction.collision_position()[1] > highest_wall {
c.wall_score = 2000.0;
}
c.score += c.wall_score;
}
}
Ok(all_miss)
}
fn apex_time(p: Prediction) -> f32 {
let v = p.request.trajectory.velocity[1];
let g = p.request.trajectory.acceleration[1];
if v < 0.0 || g >= 0.0 {
0.0
} else {
v * reciprocal(-g)
}
}
fn wall_score(
p: &mut Prediction,
reject_grind: bool,
s: &SelectorSettings,
highest: &mut f32,
line: &mut impl FnMut(Vector, Vector, f32) -> Result<Option<SurfaceHit>, String>,
) -> Result<f32, String> {
let normal = p.result.contact_normal;
if !(normal[1].abs() < 0.5) {
return Ok(0.0);
}
let start = madd(normal, s.wall_ride_test_distance, p.result.contact_position);
let end = add(start, [0.0, -10.0, 0.0, 0.0]);
let Some(hit) = line(start, end, 0.0)? else {
return Ok(0.0);
};
if !(dot(hit.normal, normal) < s.wall_ride_normal_dot_limit) {
return Ok(0.0);
}
let height = (hit.position[1] - start[1]).abs();
if reject_grind {
return Ok(height * s.wall_ride_height_score - 1000.0);
}
let position = p.collision_position();
let mut delta = sub(p.request.trajectory.position, position);
let mut slope = delta[1];
delta[1] = 0.0;
let horizontal = length(delta);
if horizontal > f32::from_bits(0x3c23_d70a) {
slope *= reciprocal(horizontal);
}
if slope > s.wall_ride_angle_allow_landing {
return Ok(height * s.wall_ride_height_score - 1000.0);
}
if height < s.wall_ride_minimum_height {
return Ok(height - 1000.0);
}
let boost = s.wall_ride_boost.evaluate(height);
if position[1] > *highest {
*highest = position[1];
}
adjust_trajectory(
&mut p.request.trajectory,
p.result.contact_frame,
[0.0, boost, 0.0, 0.0],
s.maximum_trajectory_adjust,
);
Ok(height * s.wall_ride_height_score)
}
@@ -0,0 +1,313 @@
//! Original selector Launch82D67848 and Update82D68800 with typed batch ownership.
use super::{
LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit,
Trajectory, launch,
math::*,
scoring::{self, Candidate},
};
///The current authored world contains collision triangles and no grind edges.
///Pass this only after querying that actual world's topology. It is not a
///replacement for a grind result when a world contains grind primitives.
#[derive(Clone, Copy, Debug)]
pub struct WorldWithoutGrindEdges;
#[derive(Clone, Copy, Debug)]
pub struct Selection {
pub candidate_index: usize, //9636
pub prediction: Prediction, //owning copy at1696, pointer1680
pub start_velocity: Vector, //2640
pub landing_normal: Vector, //2656
pub collision_velocity: Vector, //2672
pub collision_position: Vector, //2688
pub com_trajectory: Trajectory, //2704
pub surface_category: u32, //9640
pub wall_ride: bool, //9661
}
#[derive(Clone, Debug, Default)]
pub struct TrajectorySelector {
launch_info: Option<LaunchInfo>,
batch: Option<launch::LaunchBatch>,
candidates: Vec<Candidate>,
selection: Option<Selection>,
selected_index: Option<usize>,
grind_locked_to_middle: bool, //9653
grind_normal: Option<Vector>, //9656/2896
pass: u16, //9632
adjusted_on_vert: bool, //9651
pending: bool, //9657
valid: bool, //9658
just_changed: bool, //9660
all_predictions_missed: bool, //9662
suggested_normal: Option<Vector>, //9655/2880
}
impl TrajectorySelector {
pub fn new() -> Self {
Self::default()
}
pub fn pending(&self) -> bool {
self.pending
}
pub fn valid(&self) -> bool {
self.valid
}
pub fn just_changed(&self) -> bool {
self.just_changed
}
pub fn all_predictions_missed(&self) -> bool {
self.all_predictions_missed
}
pub fn suggested_normal(&self) -> Option<Vector> {
self.suggested_normal
}
pub fn selection(&self) -> Option<&Selection> {
self.selection.as_ref()
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn grind_locked_to_middle(&self) -> bool {
self.grind_locked_to_middle
}
pub fn grind_normal(&self) -> Option<Vector> {
self.grind_normal
}
pub fn launch_info(&self) -> Option<&LaunchInfo> {
self.launch_info.as_ref()
}
pub fn requests(&self) -> &[QueryRequest] {
self.batch.as_ref().map_or(&[], |b| b.requests.as_slice())
}
pub fn com_displacement(&self) -> Option<Vector> {
self.batch.as_ref().map(|b| b.com_displacement)
}
pub fn local_com_position(&self) -> Option<Vector> {
self.batch.as_ref().map(|b| b.local_com_position)
}
pub fn local_board_position(&self) -> Option<Vector> {
self.batch.as_ref().map(|b| b.local_board_position)
}
pub fn board_position(&self) -> Option<Vector> {
self.batch.as_ref().map(|b| b.board_position)
}
pub fn query_origin(&self) -> Option<Vector> {
self.batch.as_ref().map(|b| b.origin)
}
///Update82D68800 always clears9660 even if no batch is ready.
pub fn update_without_completion(&mut self) -> bool {
self.just_changed = false;
self.valid
}
///Ground's explicit cancellation writes9657 only; cached observations stay.
pub fn cancel_pending(&mut self) {
self.pending = false;
}
///Reset82D67228 invalidates landing observations, while retaining the
///in-flight request9657, winning result1680 and selected index9636.
pub fn reset(&mut self) {
self.valid = false;
self.suggested_normal = None;
self.grind_normal = None;
self.just_changed = false;
self.all_predictions_missed = false;
self.pass = 0;
self.grind_locked_to_middle = false;
if let Some(selection) = &mut self.selection {
selection.com_trajectory = Trajectory {
position: ZERO,
velocity: ZERO,
acceleration: ZERO,
duration: -1.0,
};
}
//The reset's remaining slots are not represented by this selector's
//player-owned, triangle-world observations.
}
pub fn launch(
&mut self,
mut info: LaunchInfo,
input: SelectorInput,
s: &SelectorSettings,
) -> Result<bool, String> {
if self.pending {
return Ok(false);
}
if info.trajectory_count == 0 {
return Err("GetLaunchInfo supplied zero trajectory candidates".into());
}
self.pass = 0;
self.valid = false;
//Launch invalidates9636 but retains the cached winning result and COM.
self.selected_index = None;
self.grind_normal = None;
self.adjusted_on_vert = launch::adjust_velocity(&mut info, input, s);
self.launch_info = Some(info);
self.launch_pass(input, s);
Ok(true)
}
fn launch_pass(&mut self, input: SelectorInput, s: &SelectorSettings) {
let info = self
.launch_info
.expect("Launch owns the packet before its batch");
self.all_predictions_missed = false;
if let Some(selection) = &mut self.selection {
selection.wall_ride = false;
}
let batch = launch::batch(info, input, s);
//Only velocities are written by InitTrajectoryInfo. Retain the other
//candidate fields across passes until scoring writes a valid result.
self.candidates
.resize_with(self.candidates.len().max(batch.requests.len()), || {
Candidate {
prediction: Prediction {
request: batch.requests[0],
result: QueryResult::miss(),
},
start_velocity: ZERO,
normal: ZERO,
collision_velocity: ZERO,
collision_position: ZERO,
score: 0.0,
wall_score: 0.0,
wall_ride: false,
}
});
for (index, c) in self
.candidates
.iter_mut()
.take(batch.requests.len())
.enumerate()
{
c.start_velocity = batch.velocities[index];
c.prediction = Prediction {
request: batch.requests[index],
result: QueryResult::miss(),
};
}
self.batch = Some(batch);
self.pending = true;
}
///Complete a real world query batch in order. A second pass is exposed by
///pending()/requests(); host scheduling preserves its next Update boundary.
pub fn complete_batch(
&mut self,
results: &[QueryResult],
input: SelectorInput,
s: &SelectorSettings,
_topology: WorldWithoutGrindEdges,
line: impl FnMut(Vector, Vector, f32) -> Result<Option<SurfaceHit>, String>,
) -> Result<bool, String> {
self.just_changed = false;
if !self.pending {
return Ok(self.valid);
}
let count = self.requests().len();
if results.len() != count {
return Err("Trajectory completion count differs from submitted batch".into());
}
self.just_changed = self.pass == 2;
self.pending = false;
self.pass += 1;
for (c, &result) in self.candidates.iter_mut().zip(results) {
c.prediction.result = result;
}
self.all_predictions_missed = scoring::score(
&mut self.candidates[..count],
self.pass,
self.adjusted_on_vert,
input,
s,
line,
)?;
let mut index = 0;
let mut best = -100_000_000.0;
for (i, c) in self.candidates.iter().take(count).enumerate() {
if c.score > best {
best = c.score;
index = i;
}
}
let c = self.candidates[index];
self.selected_index = Some(index);
//The copied COM trajectory is only recalculated on a valid-time result.
let previous_com = self.selection.map(|v| v.com_trajectory);
let mut selection = Selection {
candidate_index: index,
prediction: c.prediction,
start_velocity: c.start_velocity,
landing_normal: c.normal,
collision_velocity: c.collision_velocity,
collision_position: c.collision_position,
//Ctor82D66DC0 initializes2704/2720/2736=zero,2752=-1.
com_trajectory: previous_com.unwrap_or(Trajectory {
position: ZERO,
velocity: ZERO,
acceleration: ZERO,
duration: -1.0,
}),
surface_category: self.selection.map_or(0, |v| v.surface_category),
wall_ride: c.wall_ride,
};
if c.prediction.result.contact_time < s.minimum_valid_time {
self.suggested_normal = Some(c.prediction.result.contact_normal);
self.valid = false;
self.just_changed = false;
self.selection = Some(selection);
return Ok(false);
}
selection.surface_category = (c.prediction.result.surface >> 7) & 31;
selection.com_trajectory = self.com_trajectory(selection, input, s);
//No grind edges means native9652/9653 are false. AIPath is absent from
//this player-owned runtime, so original9659 is false as well.
self.grind_locked_to_middle = false;
let second_pass = self.pass == 1
&& count >= 2
&& !(angle_between(selection.landing_normal, input.ground_normal)
< s.minimum_normal_delta_second_pass);
self.selection = Some(selection);
self.valid = true;
if second_pass {
let info = self.launch_info.as_mut().expect("completed launch packet");
info.start_velocity = selection.start_velocity;
info.cone_angle_x = s.cone_x_second_pass;
info.cone_angle_z = s.cone_z_second_pass;
self.launch_pass(input, s);
self.pass = 2;
}
Ok(self.valid)
}
fn com_trajectory(
&self,
selection: Selection,
input: SelectorInput,
s: &SelectorSettings,
) -> Trajectory {
let info = self.launch_info.expect("completed launch packet");
let batch = self.batch.as_ref().expect("completed query batch");
let mut trajectory = selection.prediction.request.trajectory;
let scalar = s
.landing_com_scalar_vs_slope
.evaluate(selection.landing_normal[1]);
let delta = sub(info.animation_com_position, trajectory.position);
let height = dot(
input.reference_up,
sub(batch.origin, info.animation_com_position),
)
.abs()
* scalar;
let offset = scale(selection.landing_normal, height);
if selection.prediction.result.contact_frame > 0 {
trajectory.position = add(trajectory.position, delta);
launch::adjust_trajectory(
&mut trajectory,
selection.prediction.result.contact_frame,
sub(offset, delta),
s.maximum_trajectory_adjust,
);
}
trajectory
}
}
@@ -0,0 +1,105 @@
//! Launch data82D33448/82BE33D0 and the source-owned caller observations.
use super::math::{IDENTITY, Transform, Vector, ZERO};
use crate::point_graph::PointGraph;
#[derive(Clone, Copy, Debug)]
pub struct LaunchInfo {
pub reckoning_transform: Transform, //0=Reckoning816
pub reckoning_inverse: Transform, //64=Reckoning944
pub start_velocity: Vector, //128=Processed400 m/s
pub com_velocity: Vector, //144=Processed704 m/s
pub skeleton_vector_160: Vector, //160=Skeleton16208
pub skeleton_vector_176: Vector, //176=Skeleton16240
pub board_position: Vector, //192=Processed112 metres
pub animation_com_position: Vector, //208=Processed592 metres
pub start_position_override: Vector, //224 metres
pub board_position_override: Vector, //240 metres
pub cone_angle_x: f32, //256 degrees
pub cone_angle_z: f32, //260 degrees
pub timestep: f32, //264 seconds
pub player_jumped: bool, //268
pub use_position_override: bool, //269
pub trajectory_count: u16, //270 GetLaunchInfo selects1 or7
}
impl Default for LaunchInfo {
///Original ctor82D33448; Fill must supply current physical observations.
fn default() -> Self {
Self {
reckoning_transform: IDENTITY,
reckoning_inverse: IDENTITY,
start_velocity: ZERO,
com_velocity: ZERO,
skeleton_vector_160: ZERO,
skeleton_vector_176: ZERO,
board_position: ZERO,
animation_com_position: ZERO,
start_position_override: ZERO,
board_position_override: ZERO,
cone_angle_x: 0.0,
cone_angle_z: 0.0,
timestep: 0.0,
player_jumped: false,
use_position_override: false,
trajectory_count: 0,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct SelectorInput {
pub gravity: Vector, //PhysicsWorld32 m/s^2
pub ground_normal: Vector, //Processed464
pub contact_position: Vector, //496 metres
pub heading_direction: Vector, //512
pub reference_up: Vector, //544
pub board_vertical_velocity: f32, //404 m/s
pub directional_input: f32, //2636
pub previous_physics_state: u32, //2504
pub flags_2472: u32,
pub flags_2476: u32,
pub offboard_flags_1776: u32,
pub grind_lock_distance: f32, //active physics_mode80 metres
}
///Stock skater collection values; offsets refer to physics_trajectory unless stated.
#[derive(Clone, Debug)]
pub struct SelectorSettings {
pub cone_x: f32, //576 degrees, Skeleton GetLaunchInfo
pub cone_z: f32, //568 degrees, Skeleton GetLaunchInfo
pub trajectory_max_time: f32, //428 seconds
pub trajectory_max_drop: f32, //63673FB6DA092B7E metres
pub trajectory_error_start: f32, //432
pub trajectory_error_end: f32, //436
pub speed_factor_min: f32, //440 m/s
pub speed_factor_max: f32, //444 m/s
pub cone_angle_z_vs_speed: PointGraph<8>, //304
pub cone_x_second_pass: f32, //572 degrees
pub cone_z_second_pass: f32, //564 degrees
pub landing_time_bonus: PointGraph<8>, //0 x16/y48
pub landing_com_scalar_vs_slope: PointGraph<8>, //80 x96/y128
pub landing_force_scalar: PointGraph<8>, //240
pub grind_penalty_vs_distance: PointGraph<8>, //160 x176/y208
pub score_middle_bonus: f32, //468
pub score_landing_force: f32, //472
pub score_landing_direction: f32, //476
pub score_transition: f32, //456
pub surface_unrideable_score: f32, //460
pub surface_dont_align_score: f32, //464
pub natural_air_off_verts_scalar: f32, //484
pub minimum_valid_time: f32, //488 seconds
pub minimum_time_after_apex: f32, //492 seconds
pub minimum_normal_delta_second_pass: f32, //496 radians
pub maximum_trajectory_adjust: f32, //500
pub wall_ride_test_distance: f32, //416 metres
pub wall_ride_minimum_height: f32, //420 metres
pub wall_ride_angle_allow_landing: f32, //424
pub wall_ride_height_score: f32, //452
pub wall_ride_boost: PointGraph<4>, //368 x384/y400
pub wall_ride_normal_dot_limit: f32, //physics global188/layout248
pub displacement_vs_speed: PointGraph<8>, //physics_reckoning544
pub displacement_vs_ground_normal: PointGraph<8>, //physics_reckoning608
pub trajectory_radius: f32, //physics_reckoning C6710942E297D587 metres
pub trajectory_displacement: f32, //physics_reckoning runtime attribute
pub minimum_trajectory_frames: i32, //physics_reckoning runtime attribute
pub vert_jump_align_factor: f32, //0B42DFBB7F7756A0
pub vert_jump_align_max_ground_normal_y: f32, //94923D136EA2DA37
pub vert_jump_align_min_direction_y: f32, //58BFC8D41216C7EE
pub vert_jump_align_max_angle: f32, //723FF102068622CF
}
@@ -0,0 +1,159 @@
//! ControlAirLegExtension82BAF538 and its ascending/descending/preland helpers.
use super::output::attributes::AnimationAttribute;
use crate::{physics::native_arithmetic::dot3, point_graph::PointGraph};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Bone {
Board,
LeftToe,
RightToe,
}
pub struct Settings {
pub going_up_speed: f32,
pub going_down_speed: f32,
pub minimum_height: f32,
pub preland_velocity: f32,
pub preland_final_height: f32,
pub offboard_multiplier: f32,
pub arm_extension: PointGraph<4>,
}
///All vectors are preceding completed physical output in stock coordinates.
#[derive(Clone, Copy)]
pub struct Physical {
pub com_velocity: [f32; 4],
pub com_position: [f32; 4],
pub system_up: [f32; 4],
pub right_toe: [f32; 4],
pub left_toe: [f32; 4],
pub animation_height: f32,
pub offboard_316: bool,
pub remaining_air_time: f32,
}
///CreateInstance82BAF980 zeros mode8, distance12 and descending_time16.
#[derive(Default)]
pub struct State {
mode: u32,
height: f32,
descending_time: f32,
}
impl State {
pub fn needs_initial_attribute(&self) -> bool {
self.mode == 0
}
pub fn needs_prelanding_query(&self, p: Physical) -> bool {
self.mode == 2 || (self.mode == 1 && p.com_velocity[1] < 0.0)
}
///The first update seeds the height but does not also advance its new mode.
pub fn update(
&mut self,
bone: Bone,
p: Physical,
settings: &Settings,
dt: f32,
initial_attribute: Option<AnimationAttribute>,
prepare_to_land: bool,
) -> [f32; 2] {
let ascending = p.com_velocity[1] > 0.0;
match self.mode {
0 => {
self.mode = if ascending { 1 } else { 2 };
if let Some(attribute) = initial_attribute {
if attribute.kind == 0 {
//The queried union is initialized to zero before GetLastAttr.
self.height = f32::from_bits(attribute.payload.0[0].unwrap_or(0));
}
} else {
let toe = if p.offboard_316 || bone == Bone::RightToe {
Some(p.right_toe)
} else if bone == Bone::LeftToe {
Some(p.left_toe)
} else {
None
};
self.height = match toe {
Some(toe) => dot3(
std::array::from_fn(|i| p.com_position[i] - toe[i]),
p.system_up,
),
None => p.animation_height,
};
}
}
1 if p.com_velocity[1] >= 0.0 => {
//82BAFB60 is FNMSUBS, followed by FSEL against minimum1756.
self.height = maximum(
(-dt).mul_add(settings.going_up_speed, self.height),
settings.minimum_height,
);
}
1 | 2 => {
self.mode = 2;
if prepare_to_land {
self.mode = 3;
self.preland(bone, settings, dt);
} else {
//82BAFC88..FD0C: both dot products precede the fused blend.
let closing = -dot3(p.com_velocity, p.system_up);
let vertical = dot3(p.system_up, [0.0, 1.0, 0.0, 0.0]).abs();
let speed = maximum(closing, 0.0)
.mul_add(vertical, (1.0 - vertical) * settings.going_down_speed);
let speed = minimum(speed, settings.going_down_speed);
self.height =
maximum((-speed).mul_add(dt, self.height), settings.minimum_height);
}
}
3 => self.preland(bone, settings, dt),
_ => {}
}
self.descending_time = if ascending {
0.0
} else {
self.descending_time + dt
};
[
self.height,
settings.arm_extension.evaluate(self.descending_time),
]
}
fn preland(&mut self, bone: Bone, settings: &Settings, dt: f32) {
self.height = dt.mul_add(settings.preland_velocity, self.height);
if bone != Bone::Board {
self.height *= settings.offboard_multiplier;
}
}
}
///82BAFDB8 checks the named FullExtension attribute after the physical query.
pub fn prepare_to_land(
physical_query: bool,
full_extension: Option<AnimationAttribute>,
p: Physical,
s: &Settings,
) -> bool {
if !physical_query {
return false;
}
let Some(attribute) = full_extension else {
return true;
};
//GetLastAttr's scalar output remains initialized zero for nonfloat records.
let extension = if attribute.kind == 0 {
f32::from_bits(attribute.payload.0[0].unwrap_or(0))
} else {
0.0
};
if extension < s.preland_final_height - f32::from_bits(0x3dcccccd) {
return true;
}
let required_time = (extension - p.animation_height) / s.preland_velocity;
p.remaining_air_time - f32::from_bits(0x3d75c28f) < required_time
}
fn maximum(a: f32, b: f32) -> f32 {
if a - b >= 0.0 { a } else { b }
}
fn minimum(a: f32, b: f32) -> f32 {
if b - a >= 0.0 { a } else { b }
}
@@ -0,0 +1,66 @@
//! SettingBodyTilt Update82BA88A0 and CreateInstance82BA8B40.
use crate::point_graph::PointGraph;
#[derive(Clone, Debug)]
pub struct Settings {
pub body_spin_factor: PointGraph<4>,
pub ground_velocity: f32,
pub ground_acceleration: f32,
pub air_velocity: f32,
pub air_acceleration: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct Physical {
///PhysOutAnimation vector+0 X, consumed with mirrored stance sign.
pub lateral_tilt: f32,
///Expanded PhysOut bundle+8 scalar204; the curve consumes its magnitude.
pub body_spin_speed: f32,
pub filtered_category: u32,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct State {
value: f32,
velocity: f32,
was_enabled: bool,
}
impl State {
pub fn disable(&mut self) {self.was_enabled=false;}
pub fn update(
&mut self,
enabled: bool,
mirrored: bool,
p: Physical,
s: &Settings,
) -> Option<f32> {
if !self.was_enabled && enabled {
self.value = 0.0;
self.velocity = 0.0;
}
self.was_enabled = enabled;
if !enabled {
return None;
}
//82B97140 tests fullSkaterAnim15180 bit30. The nonmirrored branch
//flips the sign bit of the source X component before multiplication.
let tilt = if mirrored {
p.lateral_tilt
} else {
f32::from_bits(p.lateral_tilt.to_bits() ^ 0x80000000)
};
let target = s.body_spin_factor.evaluate(p.body_spin_speed.abs()) * tilt;
let (velocity, acceleration) = if p.filtered_category == 2 {
(s.air_velocity, s.air_acceleration)
} else {
(s.ground_velocity, s.ground_acceleration)
};
let desired = bound(target - self.value, -velocity, velocity);
let change = bound(desired - self.velocity, -acceleration, acceleration);
self.velocity += change;
self.value += self.velocity;
Some(self.value)
}
}
fn bound(value: f32, lo: f32, hi: f32) -> f32 {
let lower = if lo - value >= 0.0 { lo } else { value };
if hi - lower >= 0.0 { lower } else { hi }
}
@@ -0,0 +1,138 @@
//! Persistent channel timing from TU3 NewChannel82D1CC20, AddTime82D266A8,
//! RemoveExpiredChannels82D1DA00 and EndChannelPrematurely82D1D6A8.
use super::{clip_clock::AdvanceResult,output::attributes::AnimationAttribute};
#[derive(Clone,Copy,Debug,PartialEq)]
pub struct ChannelSettings {
pub priority:i32,
pub keep_alive:bool,
pub mirrored:bool,
pub speed:f32,
pub blend_in:f32,
pub hold_during_blend_in:bool,
pub blend_out:f32,
pub hold_during_blend_out:bool,
pub use_attributes:bool,
}
#[derive(Clone,Debug)]
pub struct ChannelPlayback {
pub settings:ChannelSettings,
pub weight:f32,
pub influence:f32,
blend_in_elapsed:f32,
blend_out_elapsed:f32,
resurrection_elapsed:f32,
resurrection_duration:f32,
flags:u32,
}
impl ChannelPlayback {
pub fn new(settings:ChannelSettings)->Self {
let flags=(u32::from(settings.keep_alive)<<28)
|(u32::from(settings.hold_during_blend_in)<<31)
|(u32::from(settings.hold_during_blend_out)<<30)
|(u32::from(settings.blend_out>0.0)<<27)
|(u32::from(settings.use_attributes)<<25);
Self {settings,weight:0.0,influence:1.0,blend_in_elapsed:0.0,blend_out_elapsed:0.0,resurrection_elapsed:0.0,resurrection_duration:0.0,flags}
}
///Called before advancing any trees, in the native actor AddTime order.
pub fn expired(&self)->bool {
if self.flags&0x08000000!=0 {self.blend_out_elapsed>=self.settings.blend_out}
else {self.flags&0x10000000==0&&self.flags&0x04000000!=0}
}
pub fn end(&mut self) {if self.blend_out_elapsed==0.0 {self.flags|=0x28000000;}}
pub fn end_with(&mut self,seconds:f32,from_last_frame:bool) {
if self.blend_out_elapsed==0.0 {
self.settings.blend_out=seconds;self.settings.hold_during_blend_out=from_last_frame;
self.flags=(self.flags&!0x40000000)|(u32::from(from_last_frame)<<30)|0x28000000;
}
}
pub fn can_transition(&self,resurrect:bool)->bool {!(self.blend_out_elapsed>0.0)||resurrect}
///StartTransition82D1D398..D47C preserves fade-in time/influence and
///priority, replaces fade-out settings, and optionally restores a fading
///channel from its existing coefficient rather than restarting at zero.
pub fn transition(&mut self,settings:ChannelSettings,resurrect:bool) {
if self.blend_out_elapsed>0.0&&resurrect {
self.resurrection_duration=settings.blend_in;
self.resurrection_elapsed=unit(1.0-self.blend_out_elapsed/self.settings.blend_out)*settings.blend_in;
}
self.blend_out_elapsed=0.0;
self.settings.keep_alive=settings.keep_alive;
self.settings.blend_out=settings.blend_out;
self.settings.hold_during_blend_out=settings.hold_during_blend_out;
self.settings.use_attributes=settings.use_attributes;
self.flags=(self.flags&!0x7a000000)|(u32::from(settings.keep_alive)<<28)
|(u32::from(settings.hold_during_blend_out)<<30)
|(u32::from(settings.blend_out>0.0)<<27)|(u32::from(settings.use_attributes)<<25);
}
///Returns whether the channel child should advance. The base has already
///advanced; fade weights use PRE-update elapsed times, then increment them.
pub fn advance(&mut self,dt:f32,length:f32,time:f32)->bool {
let mut fade_in=1.0;
if self.settings.blend_in>0.0&&self.blend_in_elapsed<self.settings.blend_in {
fade_in=unit(self.blend_in_elapsed/self.settings.blend_in);
self.blend_in_elapsed+=dt;
}
let remaining=length-time;
let remaining=if remaining>=0.0 {remaining}else{0.0};
let hold_end=self.flags&0x40000000!=0;
let ended=self.flags&0x04000000!=0;
let fade_automatically=if hold_end {ended}else{remaining<self.settings.blend_out};
let mut fade_out=1.0;
if self.settings.blend_out>0.0&&(self.flags&0x20000000!=0||(self.flags&0x10000000==0&&fade_automatically)) {
fade_out=unit(1.0-self.blend_out_elapsed/self.settings.blend_out);
self.blend_out_elapsed+=dt;
}
let mut resurrection=1.0;
if self.resurrection_duration>0.0&&self.resurrection_elapsed<self.resurrection_duration {
resurrection=unit(self.resurrection_elapsed/self.resurrection_duration);
self.resurrection_elapsed+=dt;
}
let fade=if fade_in-fade_out>=0.0 {fade_out}else{fade_in};
let fade=if fade-resurrection>=0.0 {resurrection}else{fade};
self.weight=unit(fade*self.influence);
!(self.flags&0x80000000!=0&&self.blend_in_elapsed<self.settings.blend_in)
&&!(hold_end&&self.blend_out_elapsed>0.0)
}
pub fn did_advance(&mut self,property:AdvanceResult) {
self.flags=(self.flags&!0x04000000)|(u32::from(property.crossed_end)<<26);
}
pub fn merge_attributes(&self,base:Vec<AnimationAttribute>,channel:&[AnimationAttribute])->Vec<AnimationAttribute> {
if !self.settings.use_attributes {return base;}
//82B966D0: ordered union, channel wins equal names without scaling.
let mut output=Vec::with_capacity(base.len()+channel.len());let mut b=0;let mut c=0;
while b<base.len()&&c<channel.len() {
match base[b].name.0.cmp(&channel[c].name.0) {
core::cmp::Ordering::Less=>{output.push(base[b]);b+=1;},
core::cmp::Ordering::Greater=>{output.push(channel[c]);c+=1;},
core::cmp::Ordering::Equal=>{output.push(channel[c]);b+=1;c+=1;},
}
}
output.extend_from_slice(&base[b..]);output.extend_from_slice(&channel[c..]);output
}
}
fn unit(value:f32)->f32 {let low=if -value>=0.0 {0.0}else{value};if 1.0-low>=0.0 {low}else{1.0}}
#[cfg(test)]
mod tests {
use super::*;
fn settings()->ChannelSettings {ChannelSettings {priority:0,keep_alive:false,mirrored:false,speed:1.0,blend_in:0.5,hold_during_blend_in:true,blend_out:0.5,hold_during_blend_out:false,use_attributes:false}}
#[test]
fn preincrement_fade_and_expiry_order_preserve_native_first_frame() {
let mut channel=ChannelPlayback::new(settings());
assert!(!channel.advance(0.25,2.0,0.0));assert_eq!(channel.weight,0.0);
assert!(channel.advance(0.25,2.0,0.0));assert_eq!(channel.weight,0.5);
channel.end();
assert!(channel.advance(0.25,2.0,0.25));assert_eq!(channel.weight,1.0);assert!(!channel.expired());
channel.advance(0.25,2.0,0.5);assert_eq!(channel.weight,0.5);assert!(channel.expired());
}
#[test]
fn returning_channel_restores_from_existing_fade_without_zero_restart() {
let mut settings=settings();settings.hold_during_blend_in=false;
let mut channel=ChannelPlayback::new(settings);
channel.advance(0.5,2.0,0.0);channel.end();channel.advance(0.25,2.0,0.5);
assert!(!channel.can_transition(false));assert!(channel.can_transition(true));
channel.transition(settings,true);channel.advance(0.1,2.0,0.0);
assert_eq!(channel.weight,0.5);assert!(!channel.expired());
}
}
@@ -0,0 +1,124 @@
//! Andale Clip timing: 0x82D25A00, 0x825902C0, 0x827B8BD8,
//! 0x827B8C38, 0x827B8B90; attribute status 0x82D264C0.
#[derive(Clone, Copy, Debug)]
pub struct ClipClock {
pub frames: f32,
pub fps: f32,
pub base_speed: f32,
pub speed: f32,
pub length: f32,
pub time: f32,
pub previous_time: f32,
/// Native +40; retained until pose evaluation commits previous time.
pub loops_since_evaluation: u32,
pub looping: bool,
/// Clip flags word bit 30 / native object byte +49.
pub phase_controlled: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AdvanceResult {
/// Native output byte +1. Exact equality with the end does not cross it.
pub crossed_end: bool,
/// Native output +4; unchanged on updates that do not cross the end.
pub overshoot: f32,
/// Native output +8, measured before wrapping, so it may be negative.
pub remaining_before_wrap: f32,
}
impl ClipClock {
pub fn set_time(&mut self, time: f32) {
self.time = time;
self.previous_time = time;
self.loops_since_evaluation = 0;
}
/// Preserve the native multiply/divide order when changing playback rate.
/// In particular, simplifying this to old_speed/new_speed changes rounding.
pub fn set_speed(&mut self, speed: f32) {
let new_rate = self.base_speed * speed;
let old_rate = self.speed * self.base_speed;
let frames_per_second = self.fps * self.base_speed;
let inverse_new_rate = 1.0 / new_rate;
self.time = (self.time * old_rate) * inverse_new_rate;
self.previous_time = (self.previous_time * old_rate) * inverse_new_rate;
self.length = (self.frames - 1.0) / (frames_per_second * speed);
self.speed = speed;
}
pub fn sample_time(&self) -> f32 {
if self.length - self.time >= 0.0 {
self.time
} else {
self.length
}
}
pub fn advance(&mut self, delta_seconds: f32, phase: f32, result: &mut AdvanceResult) {
if self.phase_controlled {
self.previous_time = self.time;
let lower = if -phase >= 0.0 { 0.0 } else { phase };
let phase = if 1.0 - lower >= 0.0 { lower } else { 1.0 };
self.time = self.length * phase;
self.loops_since_evaluation = u32::from(self.time < self.previous_time);
// The native phase branch does not touch the AdvanceResult fields.
return;
}
self.time += delta_seconds;
result.remaining_before_wrap = self.length - self.time;
if self.time.partial_cmp(&self.length) != Some(core::cmp::Ordering::Greater) {
result.crossed_end = false;
} else if self.looping {
// Invalid lengths cannot make progress in the native subtraction
// loop. Fail explicitly rather than hang or invent a replacement.
assert!(
self.length > 0.0 && self.time.is_finite(),
"invalid looping clip clock"
);
while self.time > self.length {
result.crossed_end = true;
result.overshoot = self.time - self.length;
let next = self.time - self.length;
assert!(
next < self.time,
"clip clock cannot advance at this precision"
);
self.time = next;
self.loops_since_evaluation = self.loops_since_evaluation.wrapping_add(1);
}
} else {
result.crossed_end = true;
result.overshoot = self.time - self.length;
}
}
/// 0x82D25C20/24, called when pose evaluation's update-history flag is set.
/// Advancing time alone does not perform this commit in the elapsed mode.
pub fn commit_evaluation(&mut self) {
self.previous_time = self.time;
self.loops_since_evaluation = 0;
}
/// Return the native status byte used by GetAttribute/GetAttributes and
/// the physics contact consumer. Times in the bank are normalized to length.
pub fn attribute_status(&self, begin: f32, end: f32) -> u8 {
if begin == -1.0 {
return 6;
}
let begin = self.length * begin;
let end = self.length * end;
let intersects = if self.loops_since_evaluation == 0 {
end > self.previous_time && begin <= self.time
} else {
end > self.previous_time || begin <= self.time
};
if !intersects {
17
} else if self.time < begin || self.time > end {
9
} else {
5
}
}
}
@@ -0,0 +1,182 @@
//! Owned host execution for the recovered SetData worker.
//!
//! 828D7E08/828D7FD8 define batch boundaries and dependency collection; preparing
//! a job is not submitting or completing it (828C88D8). This synchronous host
//! adapter accepts only SetData jobs and already-materialized input buffers.
//! It is not a replacement for the full ACS scheduler or its other engines.
use super::{
buffers::{BufferError, PoseBuffers},
set_data::{self, SetDataCommand},
};
use std::num::NonZeroU16;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct JobId {
owner: usize,
index: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueueError {
CommandCapacityExceeded,
DependencyCapacityExceeded,
/// Other engines must finish and supply their owned buffers before this
/// SetData-only adapter is constructed; unresolved handles are rejected.
UnavailableDependency,
}
struct Job {
bone_count: u16,
commands: Vec<SetDataCommand>,
dependencies: Vec<JobId>,
}
impl Job {
fn new() -> Self {
Self {
bone_count: 0,
commands: Vec::new(),
dependencies: Vec::new(),
}
}
}
pub struct SetDataQueue {
owner: usize,
buffers: PoseBuffers,
max_work_units: NonZeroU16,
prepared: Vec<Job>,
current: Job,
command_count: usize,
}
impl SetDataQueue {
pub fn new(buffers: PoseBuffers, max_work_units: NonZeroU16) -> Self {
Self {
owner: super::buffers::new_owner(),
buffers,
max_work_units,
prepared: Vec::new(),
current: Job::new(),
command_count: 0,
}
}
/// A returned job identity establishes neither execution nor output readiness.
/// None corresponds to a null dependency; current-job references are removed.
pub fn enqueue(
&mut self,
bone_count: u16,
command: SetDataCommand,
dependencies: &[Option<JobId>],
) -> Result<JobId, QueueError> {
if self.command_count == usize::from(u16::MAX) {
return Err(QueueError::CommandCapacityExceeded);
}
let full = self.current.commands.len() == usize::from(self.max_work_units.get());
let id = JobId {
owner: self.owner,
index: self.prepared.len() + usize::from(full),
};
let mut collected = if full {
Vec::new()
} else {
self.current.dependencies.clone()
};
for dependency in dependencies.iter().flatten().copied() {
if dependency == id {
continue;
}
if dependency.owner != self.owner || dependency.index >= id.index {
return Err(QueueError::UnavailableDependency);
}
if !collected.contains(&dependency) {
if collected.len() == usize::from(u8::MAX) {
return Err(QueueError::DependencyCapacityExceeded);
}
collected.push(dependency);
}
}
if full {
self.flush();
}
// The native job header is rewritten on EVERY enqueue. Its last count
// applies to external copies for all records in that job.
self.current.bone_count = bone_count;
self.current.commands.push(command);
self.current.dependencies = collected;
self.command_count += 1;
Ok(id)
}
/// Finalize pending metadata; never executes a worker or exposes its output.
pub fn flush(&mut self) -> bool {
if self.current.commands.is_empty() {
return false;
}
self.prepared
.push(std::mem::replace(&mut self.current, Job::new()));
true
}
pub fn submit(mut self) -> PendingSetData {
self.flush();
PendingSetData {
owner: self.owner,
buffers: self.buffers,
jobs: self.prepared,
}
}
/// Safe because no workers have been started by this host adapter.
pub fn cancel(self) -> PoseBuffers {
self.buffers
}
}
pub struct PendingSetData {
owner: usize,
buffers: PoseBuffers,
jobs: Vec<Job>,
}
impl PendingSetData {
/// Execute complete workers in preparation order. All accepted dependencies
/// point to earlier jobs, so their writes precede dependent reads. No native
/// concurrency ordering beyond those dependencies is asserted.
pub fn complete(mut self) -> Result<CompletedSetData, ExecutionFailure> {
for (index, job) in self.jobs.iter().enumerate() {
if let Err(cause) = set_data::execute(&mut self.buffers, job.bone_count, &job.commands)
{
return Err(ExecutionFailure {
buffers: self.buffers,
job: JobId {
owner: self.owner,
index,
},
cause,
});
}
}
Ok(CompletedSetData {
buffers: self.buffers,
})
}
}
pub struct ExecutionFailure {
/// Diagnostic/recovery storage only; this is not a completed pose certificate.
pub buffers: PoseBuffers,
pub job: JobId,
pub cause: BufferError,
}
pub struct CompletedSetData {
buffers: PoseBuffers,
}
impl CompletedSetData {
pub fn buffers(&self) -> &PoseBuffers {
&self.buffers
}
/// Reuse can begin only after completion releases ownership.
pub fn into_buffers(self) -> PoseBuffers {
self.buffers
}
}
@@ -0,0 +1,138 @@
//! Typed animation allocations. Slots identify whole allocations, never addresses.
use crate::animation::output::{NativeMatrix, Sqt};
use std::marker::PhantomData;
use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) fn new_owner() -> usize {
static NEXT_OWNER: AtomicUsize = AtomicUsize::new(0);
NEXT_OWNER
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
value.checked_add(1)
})
.expect("animation storage identity exhausted")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Buffer<T> {
slot: usize,
owner: usize,
marker: PhantomData<T>,
}
impl<T: Copy> Buffer<T> {
pub fn at(self, first_bone: usize) -> BoneSlice<T> {
BoneSlice {
buffer: self,
first_bone,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BoneSlice<T> {
pub buffer: Buffer<T>,
pub first_bone: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BufferError {
UnknownBuffer,
RangeOutsideAllocation,
UninitializedBone {
bone: usize,
},
/// The native copy helper's partially overlapping behavior is not recovered.
UnsupportedPartialOverlap,
}
pub struct BoneBuffers<T> {
owner: usize,
allocations: Vec<Vec<Option<T>>>,
}
impl<T: Copy> Default for BoneBuffers<T> {
fn default() -> Self {
Self {
owner: new_owner(),
allocations: Vec::new(),
}
}
}
impl<T: Copy> BoneBuffers<T> {
/// Allocate exactly the requested extent without inventing a bind pose.
pub fn allocate(&mut self, count: usize) -> Buffer<T> {
self.insert(vec![None; count])
}
pub fn initialized(&mut self, values: impl IntoIterator<Item = T>) -> Buffer<T> {
self.insert(values.into_iter().map(Some).collect())
}
fn insert(&mut self, allocation: Vec<Option<T>>) -> Buffer<T> {
let slot = self.allocations.len();
self.allocations.push(allocation);
Buffer {
slot,
owner: self.owner,
marker: PhantomData,
}
}
fn range(
&self,
source: BoneSlice<T>,
count: usize,
) -> Result<std::ops::Range<usize>, BufferError> {
if source.buffer.owner != self.owner {
return Err(BufferError::UnknownBuffer);
}
let allocation = self
.allocations
.get(source.buffer.slot)
.ok_or(BufferError::UnknownBuffer)?;
let end = source
.first_bone
.checked_add(count)
.ok_or(BufferError::RangeOutsideAllocation)?;
if end > allocation.len() {
return Err(BufferError::RangeOutsideAllocation);
}
Ok(source.first_bone..end)
}
pub fn read(&self, source: BoneSlice<T>, count: usize) -> Result<Vec<T>, BufferError> {
if count == 0 {
return Ok(Vec::new());
}
let range = self.range(source, count)?;
range
.map(|bone| {
self.allocations[source.buffer.slot][bone]
.ok_or(BufferError::UninitializedBone { bone })
})
.collect()
}
pub(crate) fn copy(
&mut self,
source: BoneSlice<T>,
destination: BoneSlice<T>,
count: usize,
) -> Result<(), BufferError> {
if count == 0 {
return Ok(());
}
let from = self.range(source, count)?;
let to = self.range(destination, count)?;
if source.buffer.slot == destination.buffer.slot {
if from == to {
return Ok(());
}
if from.start < to.end && to.start < from.end {
return Err(BufferError::UnsupportedPartialOverlap);
}
}
// Copy validity alongside the value: native SET does not initialize
// unwritten bones. Such exports remain unreadable, rather than zero-filled.
for (from, to) in from.zip(to) {
self.allocations[destination.buffer.slot][to] =
self.allocations[source.buffer.slot][from];
}
Ok(())
}
}
#[derive(Default)]
pub struct PoseBuffers {
pub sqt: BoneBuffers<Sqt>,
pub matrices: BoneBuffers<NativeMatrix>,
}
@@ -0,0 +1,4 @@
//! Typed animation-command storage and recovered workers.
pub mod batch;
pub mod buffers;
pub mod set_data;
@@ -0,0 +1,60 @@
//! ACSsetData_job (TU3 0x828D80B8), expressed in bones and typed allocations.
use super::buffers::{BoneBuffers, BoneSlice, BufferError, PoseBuffers};
use crate::animation::output::{NativeMatrix, Sqt};
#[derive(Clone, Copy, Debug)]
pub enum InternalUpdate<T> {
/// Native opcode 12: byte-sized partial SET count.
Set {
source: BoneSlice<T>,
bone_count: u8,
},
/// Every other opcode skips the SET copy, but still permits extraction.
Preserve,
}
#[derive(Clone, Copy, Debug)]
pub struct CopyCommand<T> {
pub update: InternalUpdate<T>,
pub internal: BoneSlice<T>,
pub external: Option<BoneSlice<T>>,
}
#[derive(Clone, Copy, Debug)]
pub enum SetDataCommand {
Sqt(CopyCommand<Sqt>),
Matrix(CopyCommand<NativeMatrix>),
}
/// 828D7E08 uses the low byte of numExtBones when the encoded byte is zero.
pub fn resolve_set_count(encoded_count: u8, external_bone_count: u32) -> u8 {
if encoded_count == 0 {
external_bone_count as u8
} else {
encoded_count
}
}
pub(crate) fn execute(
buffers: &mut PoseBuffers,
bone_count: u16,
commands: &[SetDataCommand],
) -> Result<(), BufferError> {
for command in commands {
match command {
SetDataCommand::Sqt(command) => copy(&mut buffers.sqt, bone_count, command)?,
SetDataCommand::Matrix(command) => copy(&mut buffers.matrices, bone_count, command)?,
}
}
Ok(())
}
fn copy<T: Copy>(
buffers: &mut BoneBuffers<T>,
skeleton_bones: u16,
command: &CopyCommand<T>,
) -> Result<(), BufferError> {
if let InternalUpdate::Set { source, bone_count } = command.update {
buffers.copy(source, command.internal, usize::from(bone_count))?;
}
if let Some(external) = command.external {
// Read internal after SET, including writes through earlier aliases.
buffers.copy(command.internal, external, usize::from(skeleton_bones))?;
}
Ok(())
}
@@ -0,0 +1,108 @@
//! Stock riding crouch/absorption and auto-pump, TU3 82BB7DA0/82BB8818.
use crate::point_graph::PointGraph;
#[derive(Clone,Debug)]
pub struct Settings {
pub maximum_height:PointGraph<8>,pub absorption_upforce:PointGraph<8>,pub pump_maxspeed:PointGraph<8>,
pub minimum_height:f32,pub maximum_ratio:f32,pub maximum_delta_delta:f32,pub maximum_delta:f32,pub input_blend:f32,
pub pump_vertical_speed:f32,pub maximum_crouch_from_deck:f32,pub skateboard_damping:f32,pub maximum_force:f32,pub maximum_ground_force:f32,pub absorption_factor:f32,
pub auto_pump:AutoPumpSettings,
}
#[derive(Clone,Debug)]
pub struct AutoPumpSettings {
pub maximum_crouch:PointGraph<4>,pub sufficient_crouch:f32,pub standing_threshold:f32,pub rise_speed:f32,pub pump_speed:f32,
pub potential_threshold:f32,pub potential_blend:f32,pub intent_magnitude_start:f32,pub intent_angle_region:f32,pub crouch_time:f32,pub crouch_speed:f32,
}
/// Resolved physical publication fields. Offsets stay explicit until the
/// producer's domain identity is verified; callers must publish actual values.
#[derive(Clone,Copy,Debug)]
pub struct Physical {
pub body_84:f32,pub body_164:f32,pub body_188:f32,
pub force_516:f32,pub ground_force_520:f32,pub minimum_crouch_528:f32,pub deck_angle_532:f32,
pub animation_height_72:f32,
}
#[derive(Clone,Copy,Debug)]
pub struct Intents {
pub auto_pump_angle:Option<f32>,pub auto_pump_magnitude:Option<f32>,pub crouch:Option<f32>,pub hard_turn_crouch:Option<f32>,pub manual:Option<f32>,
/// SkaterMotionGraph interface+108, used by both manual-crouch branches.
pub motion_flag_108:bool,
}
#[derive(Clone,Copy,Debug)]
pub struct State {
absorbed_velocity:f32,filtered_input:f32,pub fraction:f32,delta_fraction:f32,player_pumping:bool,
auto_fraction:f32,auto_state:u32,auto_time:f32,previous_angle:f32,previous_magnitude:f32,filtered_potential:f32,pump_idle_time:f32,
}
#[derive(Clone,Copy,Debug)]
pub struct Output {pub height:f32,pub new_auto_pump:bool,pub player_controlled_pump:bool}
impl State {
pub fn begin(physical:Physical,settings:&Settings)->Self {
let maximum=settings.maximum_height.evaluate(physical.body_164);
let fraction=1.0-unit((physical.animation_height_72-settings.minimum_height)/(maximum-settings.minimum_height));
Self {absorbed_velocity:0.0,filtered_input:0.0,fraction,delta_fraction:0.0,player_pumping:false,auto_fraction:0.0,auto_state:0,auto_time:0.0,previous_angle:0.0,previous_magnitude:0.0,filtered_potential:0.0,pump_idle_time:0.5}
}
pub fn update(&mut self,p:Physical,intents:Intents,dt:f32,s:&Settings)->Output {
let restoring=-s.absorption_upforce.evaluate(1.0-self.fraction);
let mut board=bound(-p.force_516/s.maximum_force,-1.0,1.0);
self.absorbed_velocity+=p.body_84;
self.absorbed_velocity=s.skateboard_damping*self.absorbed_velocity;
if self.absorbed_velocity<0.0 {board=-board;}
let ground=bound(-p.ground_force_520/s.maximum_ground_force,-1.0,1.0);
let mut absorption=if board<0.0 {if ground>0.0 {ground+board} else {board}} else {maximum(board,ground)};
if absorption<0.0 {if !(absorption<restoring) {absorption=restoring;}} else {absorption+=restoring;}
let angle=intents.auto_pump_angle.unwrap_or(0.0);
let magnitude=intents.auto_pump_magnitude.unwrap_or(0.0);
let a=&s.auto_pump;
let started=self.previous_angle.abs()<a.intent_angle_region && self.previous_magnitude<a.intent_magnitude_start
&& angle.abs()<a.intent_angle_region && magnitude>a.intent_magnitude_start;
self.previous_angle=angle;self.previous_magnitude=magnitude;
self.filtered_potential=p.body_188.mul_add(a.potential_blend,(1.0-a.potential_blend)*self.filtered_potential);
let sufficient_potential=!(self.filtered_potential<a.potential_threshold);
if started {self.auto_time=0.0;self.auto_state=1;}
if self.auto_state==1 && self.auto_time>a.crouch_time {self.auto_state=2;}
let new_auto_pump=(self.auto_state==1 || self.auto_state==2) && self.fraction>a.sufficient_crouch && sufficient_potential;
if new_auto_pump {self.auto_state=3;}
if (self.auto_state==2 || self.auto_state==3) && !(self.fraction>a.standing_threshold+p.minimum_crouch_528) {self.auto_state=0;}
match self.auto_state {
0=>self.auto_fraction=0.0,
1=>{
self.auto_fraction=minimum(a.maximum_crouch.evaluate(p.body_164),a.crouch_speed+self.auto_fraction);
self.auto_time=minimum(a.crouch_time+1.0,dt+self.auto_time);
},
2=>self.auto_fraction=nonnegative(self.auto_fraction-a.rise_speed),
3=>self.auto_fraction=nonnegative(self.auto_fraction-a.pump_speed),
_=>{},
}
let mut crouch=intents.crouch.unwrap_or(0.0);
if !intents.motion_flag_108 {crouch=maximum(crouch,intents.hard_turn_crouch.unwrap_or(0.0));}
if intents.motion_flag_108 && intents.manual.is_some_and(|value|value<0.0) {crouch=maximum(crouch,f32::from_bits(0x3ecccccd));}
let blend=unit(s.input_blend);
self.filtered_input=maximum(crouch,self.auto_fraction).mul_add(blend,(1.0-blend)*self.filtered_input);
if self.filtered_input.abs()<f32::from_bits(0x3ba3d70a) {self.filtered_input=0.0;}
let mut delta=s.absorption_factor*absorption;
if crouch>0.0 || self.auto_fraction>0.0 || (self.player_pumping && self.fraction>p.minimum_crouch_528) {
self.player_pumping=true;
delta=maximum(minimum(1.0,maximum(p.minimum_crouch_528,self.filtered_input))-self.fraction,-s.maximum_delta);
self.pump_idle_time=0.0;
} else {
self.player_pumping=false;
// Native literal820849C8, deliberately not graph delta time.
self.pump_idle_time+=f32::from_bits(0x3c888889);
}
let player_controlled_pump=self.pump_idle_time<0.5;
let minimum_delta=-(s.pump_maxspeed.evaluate(1.0-self.fraction)*s.pump_vertical_speed);
let maximum_delta=s.pump_maxspeed.evaluate(self.fraction)*s.pump_vertical_speed;
let delta=bound(delta,minimum_delta,maximum_delta);
self.delta_fraction=bound(delta,self.delta_fraction-s.maximum_delta_delta,s.maximum_delta_delta+self.delta_fraction);
self.fraction=bound(self.fraction+self.delta_fraction,p.minimum_crouch_528,1.0);
let from_deck=s.maximum_crouch_from_deck*p.deck_angle_532;
if from_deck>self.fraction {self.fraction=from_deck;}
let height_ratio=unit((-s.maximum_ratio).mul_add(self.fraction,1.0));
let maximum_height=s.maximum_height.evaluate(p.body_164);
Output {height:(maximum_height-s.minimum_height).mul_add(height_ratio,s.minimum_height),new_auto_pump,player_controlled_pump}
}
}
fn minimum(a:f32,b:f32)->f32 {if a-b>=0.0 {b} else {a}}
fn maximum(a:f32,b:f32)->f32 {if a-b>=0.0 {a} else {b}}
fn bound(v:f32,lo:f32,hi:f32)->f32 {minimum(hi,maximum(lo,v))}
fn nonnegative(v:f32)->f32 {if -v>=0.0 {0.0} else {v}}
fn unit(v:f32)->f32 {minimum(1.0,nonnegative(v))}
@@ -0,0 +1,71 @@
//! SkeletonIK::BlendTransforms82BEEEB8.
use super::{
math::{interpolate, interpolate_affine},
status::{LimbStatus, Mode},
transforms::{LimbBinding, LimbFrames},
};
use crate::physics::{
skeleton_animation_record::{
AnimationPartTransform as Transform, compose_affine, transform_point,
},
skeleton_root::orthonormalize,
};
pub fn update(
statuses: &[LimbStatus; 4],
frames: &mut [LimbFrames; 4],
bindings: &[LimbBinding; 4],
physical_board: &Transform,
animation_to_world: &Transform,
animated_board: &Transform,
) {
// IK160 supplies only the physical board rotation. Translation comes from
// the actual animation record's board transformed through IK32.
let mut board = *physical_board;
board[3] = transform_point(animation_to_world, animated_board[3]);
for ((status, frame), binding) in statuses.iter().zip(frames).zip(bindings) {
match status.mode {
Mode::Disabled => continue,
Mode::OnDeck | Mode::External | Mode::Local => {}
}
if status.board_blend > 0.0 {
let target = compose_affine(&board, &frame.board);
frame.world = board_blend(&frame.world, &target, status.board_blend);
if binding.parent_part.is_some() {
let target = compose_affine(&board, &frame.parent_board);
frame.parent_world = board_blend(&frame.parent_world, &target, status.board_blend);
}
}
if matches!(status.mode, Mode::External | Mode::Local) {
if status.external_blend >= 1.0 {
frame.world = frame.external_world;
frame.parent_world = frame.external_parent_world;
} else {
// The native external stage also updates the retained parent
// frame for a limb without a parent, including at zero weight.
frame.world =
interpolate_affine(&frame.world, &frame.external_world, status.external_blend);
frame.parent_world = interpolate_affine(
&frame.parent_world,
&frame.external_parent_world,
status.external_blend,
);
}
}
}
}
fn board_blend(original: &Transform, target: &Transform, weight: f32) -> Transform {
let translation = core::array::from_fn(|lane| {
target[3][lane].mul_add(weight, original[3][lane] * (1.0 - weight))
});
// Each source frame is orthonormalized independently in Z/Y/X order before
// the matrix interpolation call; translation is replaced afterwards.
let mut a = orthonormalize(*original);
let mut b = orthonormalize(*target);
a[3] = [0.0; 4];
b[3] = [0.0; 4];
let (mut output, _) = interpolate(&a, &b, weight);
output[3] = translation;
output
}
@@ -0,0 +1,159 @@
//! Foot contact history and correction82BEFC80, helpers82BF04C8/82BF0558.
use super::{
status::{LimbStatus, Mode},
transforms::LimbFrames,
};
use crate::physics::skeleton_animation_record::{
AnimationPartTransform as Transform, transform_point,
};
#[derive(Clone, Copy, Debug, Default)]
pub struct FootContact {
/// SkeletonIK3104:0 pending,1 previous query hit,2 previous query missed.
pub query_state: u32,
pub position: [f32; 4],
pub desired_offset: f32,
pub offset: f32,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ContactState {
pub feet: [FootContact; 2],
///3184 persists until Reset;3185 is cleared at each UpdateContact entry.
pub support_failed: bool,
pub support_failed_this_update: bool,
}
pub struct ContactInput<'a> {
pub flags_2468: u32,
pub contact_bone: usize,
pub foot_bones: [usize; 2],
pub hips_world_position: [f32; 4],
pub inverse_board: &'a Transform,
pub board: &'a Transform,
/// Actual cached line tests from Processed960/1008. Current observations
/// replace the retained query only AFTER the previous query is consumed.
pub current_contacts: [Option<[f32; 4]>; 2],
}
impl ContactState {
pub fn update(
&mut self,
statuses: &[LimbStatus; 4],
frames: &mut [LimbFrames; 4],
input: ContactInput<'_>,
) {
self.support_failed_this_update = false;
let requires_support = input.flags_2468 & 0x4200_0000 != 0;
for foot in 0..2 {
let contact = &mut self.feet[foot];
let frame = &mut frames[foot];
let mut failed = false;
if statuses[foot].mode == Mode::OnDeck && input.contact_bone == input.foot_bones[foot] {
match contact.query_state {
1 => {
let local = transform_point(input.inverse_board, contact.position);
let height = local[1] + f32::from_bits(0x3DA8_F5C3);
if height <= f32::from_bits(0x3E99_999A) {
contact.desired_offset = if input.flags_2468 & 0x0180_0000 == 0 {
limit_offset(height)
} else {
0.0
};
approach(contact);
let distance = distance_to_contact(
contact,
frame,
input.board,
input.inverse_board,
);
//82BEFFB4..FFCC: fix modest penetration immediately;
//larger separations retain the bounded transition.
if distance < 0.0 && distance > f32::from_bits(0xBE99_999A) {
contact.offset -= distance;
}
add_offset(frame, input.board, contact.offset);
failed = requires_support
&& distance > f32::from_bits(0x3CF5_C28F)
&& contact.offset < f32::from_bits(0xBC23_D70A);
} else {
contact.desired_offset = limit_offset(contact.desired_offset);
approach(contact);
add_offset(frame, input.board, contact.offset);
failed = requires_support
|| input.hips_world_position[1] > f32::from_bits(0x3F33_3333);
}
}
0 | 2 => {
contact.desired_offset = limit_offset(contact.desired_offset);
approach(contact);
add_offset(frame, input.board, contact.offset);
failed = contact.query_state == 2 && requires_support;
}
_ => {}
}
if let Some(position) = input.current_contacts[foot] {
contact.position = position;
contact.query_state = 1;
} else {
contact.query_state = 2;
}
} else {
contact.query_state = 0;
contact.desired_offset = 0.0;
approach(contact);
add_offset(frame, input.board, contact.offset);
}
if failed {
self.support_failed = true;
self.support_failed_this_update = true;
}
}
}
}
///82BF0558: compare both positions in inverse-board space, retaining the
///separate point transforms and the .025 foot-surface distance adjustment.
fn distance_to_contact(
contact: &FootContact,
frame: &LimbFrames,
board: &Transform,
inverse: &Transform,
) -> f32 {
let local_contact = transform_point(inverse, contact.position);
let delta = contact_offset(board, contact.offset);
let foot = core::array::from_fn(|i| frame.world[3][i] + delta[i]);
let local_foot = transform_point(inverse, foot);
(local_foot[1] - f32::from_bits(0x3CCC_CCCD)) - local_contact[1]
}
fn add_offset(frame: &mut LimbFrames, board: &Transform, offset: f32) {
let delta = contact_offset(board, offset);
for (i, value) in delta.into_iter().enumerate() {
frame.world[3][i] += value;
frame.parent_world[3][i] += value;
}
}
fn contact_offset(board: &Transform, offset: f32) -> [f32; 4] {
core::array::from_fn(|i| {
let x = board[0][i] * 0.0;
let y = board[1][i].mul_add(offset, x);
board[2][i].mul_add(0.0, y)
})
}
fn select(test: f32, nonnegative: f32, negative: f32) -> f32 {
if test >= 0.0 { nonnegative } else { negative }
}
fn limit_offset(value: f32) -> f32 {
let min = f32::from_bits(0xBDCC_CCCD);
let max = f32::from_bits(0x3DCC_CCCD);
let lower = select(min - value, min, value);
select(max - lower, lower, max)
}
fn approach(contact: &mut FootContact) {
let min = f32::from_bits(0xBC75_C28F);
let max = f32::from_bits(0x3C75_C28F);
let delta = contact.desired_offset - contact.offset;
let lower = select(min - delta, min, delta);
contact.offset = select(max - lower, lower, max) + contact.offset;
}
@@ -0,0 +1,231 @@
//! SkeletonIK::UpdateDriveFrames82BF0198 and its solve/mapping chain.
use super::{
math::{Vector, cross, length, normalize},
status::{LimbStatus, Mode},
transforms::{LimbBinding, LimbFrames, inverse_rigid},
two_bone::{self, AngleLimits, SolveResult},
};
use crate::physics::{
native_arithmetic::dot3,
skeleton_animation_record::{
AnimationPartTransform as Transform, compose_affine, transform_point,
},
skeleton_root::orthonormalize,
};
#[derive(Clone, Debug)]
pub struct Geometry {
/// SkeletonData4704: nearest ancestor represented by a physical part.
pub parents: [Option<usize>; 24],
/// SkeletonData1536: native cofactor inverse of the authored part frames.
pub inverse_part_frames: [Transform; 24],
}
impl Geometry {
/// SkeletonData Init82BD6C40 builds these inverses once from the real
/// PhysicsParamBoneData frames. It does not assume an orthogonal basis.
pub fn new(
parents: [Option<usize>; 24],
part_frames: &[Transform; 24],
) -> Result<Self, String> {
for part in 0..24 {
let mut parent = parents[part];
let mut visited = [false; 24];
visited[part] = true;
while let Some(index) = parent {
if index >= 24 || visited[index] {
return Err(format!(
"Invalid physical ancestor chain for IK part {part}"
));
}
visited[index] = true;
parent = parents[index];
}
}
Ok(Self {
parents,
inverse_part_frames: part_frames.map(|frame| super::math::inverse_affine(&frame)),
})
}
pub fn validate_limbs(&self, bindings: &[LimbBinding; 4]) -> Result<(), String> {
for binding in bindings {
let end = binding.parent_part.unwrap_or(binding.part);
if binding.part >= 24
|| end >= 24
|| self.parents[end].and_then(|p| self.parents[p]).is_none()
{
return Err(format!(
"IK part {end} has no two-bone physical ancestor chain"
));
}
}
Ok(())
}
}
pub fn update(
statuses: &[LimbStatus; 4],
frames: &mut [LimbFrames; 4],
bindings: &[LimbBinding; 4],
geometry: &Geometry,
original_animation: &[Transform; 24],
drives: &mut [Transform; 24],
world_to_animation: &Transform,
limits: AngleLimits,
) -> [bool; 4] {
let mut updated = [false; 4];
for limb in 0..4 {
if statuses[limb].mode == Mode::Disabled {
continue;
}
let binding = bindings[limb];
let target = compose_affine(world_to_animation, &frames[limb].world);
let parent_target = binding
.parent_part
.map(|_| compose_affine(world_to_animation, &frames[limb].parent_world));
let driven = binding.parent_part.unwrap_or(binding.part);
let driven_target = parent_target.as_ref().unwrap_or(&target);
let endpoint = transform_point(driven_target, geometry.inverse_part_frames[driven][3]);
let offset = subtract(endpoint, original_animation[driven][3]);
//82BF01DC..1F4: these are maximum root-to-target distances passed
//through f1 into82BD41B0, not animation blend weights.
let maximum_distance = if limb < 2 {
f32::from_bits(0x3F4C_CCCD)
} else {
1.0
};
if let Some(residual) = solve_drives(
driven,
offset,
maximum_distance,
geometry,
original_animation,
drives,
limits,
) {
drives[binding.part] = target;
add_translation(&mut drives[binding.part], residual);
// The source also adds the same returned delta to retained world
//targets. Do not insert an extra rotation absent from82BF0458.
add_translation(&mut frames[limb].world, residual);
if let Some(parent) = binding.parent_part {
drives[parent] = parent_target.unwrap();
add_translation(&mut drives[parent], residual);
add_translation(&mut frames[limb].parent_world, residual);
}
updated[limb] = true;
}
}
updated
}
///82BF0FA8, using original animation joint centers and mutable volume frames.
fn solve_drives(
end_part: usize,
offset: Vector,
maximum_distance: f32,
geometry: &Geometry,
original: &[Transform; 24],
drives: &mut [Transform; 24],
limits: AngleLimits,
) -> Option<Vector> {
let middle_part = geometry.parents[end_part]?;
let root_part = geometry.parents[middle_part]?;
let [root, middle, end] = [
original[root_part][3],
original[middle_part][3],
original[end_part][3],
];
let requested: Vector = core::array::from_fn(|i| end[i] + offset[i]);
let mut target = clamp_point(requested, root, maximum_distance);
let mut solved_middle = [0.0; 4];
if two_bone::solve(
root,
middle,
end,
&mut solved_middle,
&mut target,
limits,
true,
2,
) == SolveResult::Invalid
{
return None;
}
let residual = subtract(target, requested);
let original_normal = cross(subtract(root, middle), subtract(end, middle));
let solved_normal = cross(
subtract(root, solved_middle),
subtract(target, solved_middle),
);
let normal = normalize_safe(core::array::from_fn(|i| {
original_normal[i] + solved_normal[i]
}));
let middle_mapping = line_mapping(middle, end, normal, solved_middle, target, normal)?;
let root_mapping = line_mapping(root, middle, normal, root, solved_middle, normal)?;
drives[root_part] = orthonormalize(compose_affine(&root_mapping, &drives[root_part]));
drives[middle_part] = orthonormalize(compose_affine(&middle_mapping, &drives[middle_part]));
Some(residual)
}
///82BD41B0 clamps a point relative to the original chain root using scalar
///division after the two-refinement length; it is not limit_length's reciprocal.
fn clamp_point(point: Vector, origin: Vector, maximum: f32) -> Vector {
let delta = subtract(point, origin);
let distance = length(delta);
if maximum >= distance {
point
} else {
let scale = maximum / distance;
core::array::from_fn(|i| delta[i].mul_add(scale, origin[i]))
}
}
///82BF1F20, two82BF1D28 frames and target * rigidInverse(original).
pub(super) fn line_mapping(
start: Vector,
end: Vector,
normal: Vector,
new_start: Vector,
new_end: Vector,
new_normal: Vector,
) -> Option<Transform> {
let original = line_frame(start, end, normal)?;
let target = line_frame(new_start, new_end, new_normal)?;
Some(compose_affine(&target, &inverse_rigid(&original)))
}
fn line_frame(start: Vector, end: Vector, normal: Vector) -> Option<Transform> {
let line = subtract(end, start);
let across = cross(line, normal);
let third = cross(across, line);
let x = normalize_safe(across);
let z = normalize_safe(third);
let y = normalize_safe(line);
let product = (dot3(z, z) * dot3(x, x)) * dot3(y, y);
//830BD300 <-821647E0, initializer82F82690.
if product > f32::from_bits(0x3780_0000) {
Some([x, y, z, start])
} else {
None
}
}
pub(super) fn normalize_safe(value: Vector) -> Vector {
let magnitude = length(value);
let normalized = normalize(value);
//830BD350 <-82181A88; no arbitrary fallback axis for a degenerate line.
if magnitude > f32::from_bits(0x3586_37BD) {
normalized
} else {
[0.0; 4]
}
}
fn subtract(a: Vector, b: Vector) -> Vector {
core::array::from_fn(|i| a[i] - b[i])
}
fn add_translation(frame: &mut Transform, offset: Vector) {
for lane in 0..4 {
frame[3][lane] += offset[lane];
}
}
@@ -0,0 +1,91 @@
//! SkeletonIK::CalculateExternalIKOffset82BEE390.
use super::{
math::{axis_rotation, cross, length, limit_length, reciprocal},
status::{LimbStatus, Mode},
transforms::{LimbBinding, LimbFrames, inverse_rigid},
};
use crate::{
physics::skeleton_animation_record::{
AnimationPartTransform as Transform, IDENTITY, compose_affine, transform_point,
},
trigonometry::asin,
};
#[derive(Clone, Copy, Debug, Default)]
pub struct ExternalTarget {
/// SkeletonIK544 /608; setters distinguish the supplied coordinate space.
pub world_position: [f32; 4],
pub animation_position: [f32; 4],
/// SkeletonIK736,800,828. The normal is measured against world up.
pub normal: [f32; 4],
pub normal_set: bool,
pub normal_blend: f32,
}
pub fn update(
target: &mut ExternalTarget,
status: &mut LimbStatus,
frames: &mut LimbFrames,
binding: LimbBinding,
animation: &[Transform; 24],
animation_to_world: &Transform,
world_to_animation: &Transform,
) {
let animated = &animation[binding.part];
let original = compose_affine(animation_to_world, animated);
let mut rotation = IDENTITY;
if target.normal_blend > 0.0 {
// Native v16 is the fixed Y basis82139A20, not the animated limb's up.
let axis = cross([0.0, 1.0, 0.0, 0.0], target.normal);
let magnitude = length(axis);
if magnitude > 0.05 {
let inverse = reciprocal(magnitude, 2);
let axis = axis.map(|v| inverse * v);
let angle = asin(magnitude);
let angle = if -0.71 - angle >= 0.0 { -0.71 } else { angle };
let angle = if 0.71 - angle >= 0.0 { angle } else { 0.71 };
rotation = axis_rotation(axis, angle * target.normal_blend);
}
if !target.normal_set {
let next = target.normal_blend - 0.2;
target.normal_blend = if -next >= 0.0 { 0.0 } else { next };
}
}
if status.mode == Mode::Local {
let weight = status.target_blend;
let desired: [f32; 4] = core::array::from_fn(|lane| {
(target.animation_position[lane] - animated[3][lane]).mul_add(
weight,
status.external_target_local_delta[lane] * (1.0 - weight),
)
});
let change = limit_length(
core::array::from_fn(|lane| desired[lane] - status.external_target_local_delta[lane]),
0.025,
);
for lane in 0..4 {
status.external_target_local_delta[lane] += change[lane];
// The source publishes desired before its history limit. Do not
// replace this with the separately limited retained delta.
target.animation_position[lane] = animated[3][lane] + desired[lane];
}
target.world_position = transform_point(animation_to_world, target.animation_position);
} else {
target.animation_position = transform_point(world_to_animation, target.world_position);
}
let offset = limit_length(
core::array::from_fn(|lane| target.world_position[lane] - original[3][lane]),
0.7,
);
let mut from_origin = IDENTITY;
from_origin[3] = original[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000));
let rotated = compose_affine(&rotation, &from_origin);
let mut to_origin = IDENTITY;
to_origin[3] = core::array::from_fn(|lane| original[3][lane] + offset[lane]);
let adjustment = compose_affine(&to_origin, &rotated);
frames.external_world = compose_affine(&adjustment, &original);
if let Some(parent) = binding.parent_part {
let relative = compose_affine(&inverse_rigid(animated), &animation[parent]);
frames.external_parent_world = compose_affine(&frames.external_world, &relative);
}
}
@@ -0,0 +1,224 @@
//! Matrix interpolation used by SkeletonIK, TU3 82BD3150/82BD2E60.
//! The shared Xenon estimate primitives remain host approximations pending
//! hardware comparison; the recovered branch and refinement order is retained.
use crate::{
input::angle::atan,
physics::{
board_motion_output::inverse_length_squared,
native_arithmetic::{dot3, reciprocal_estimate},
skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY},
},
trigonometry::sin_cos,
};
pub(super) type Vector = [f32; 4];
pub(super) fn reciprocal(value: f32, refinements: usize) -> f32 {
let mut inverse = reciprocal_estimate(value);
for _ in 0..refinements {
let error = (-inverse).mul_add(value, 1.0);
inverse = inverse.mul_add(error, inverse);
}
inverse
}
pub(super) fn length(value: Vector) -> f32 {
let squared = dot3(value, value);
let result = squared * inverse_length_squared(squared, 2);
if squared == 0.0 { 0.0 } else { result }
}
pub(super) fn normalize(value: Vector) -> Vector {
let inverse = inverse_length_squared(dot3(value, value), 2);
value.map(|v| v * inverse)
}
pub(super) fn cross(left: Vector, right: Vector) -> Vector {
[
(-left[2]).mul_add(right[1], left[1] * right[2]),
(-left[0]).mul_add(right[2], left[2] * right[0]),
(-left[1]).mul_add(right[0], left[0] * right[1]),
(-left[3]).mul_add(right[3], left[3] * right[3]),
]
}
///82BD3D90. The very short-vector branch preserves the original fourth lane.
pub(super) fn limit_length(value: Vector, limit: f32) -> Vector {
let magnitude = length(value);
if !(magnitude >= f32::from_bits(0x3780_0000)) {
return value;
}
let capped = if limit - magnitude >= 0.0 {
magnitude
} else {
limit
};
let inverse = reciprocal(magnitude, 2);
value.map(|v| (v * capped) * inverse)
}
///82BD2E60's axis/angle outputs. The unused screw-pivot output is omitted.
pub(super) fn rotation_axis_angle(rotation: &Transform) -> (Vector, f32) {
let skew = [
rotation[1][2] - rotation[2][1],
rotation[2][0] - rotation[0][2],
rotation[0][1] - rotation[1][0],
0.0,
];
let sine_twice = length(skew);
let cosine_twice = ((rotation[0][0] + rotation[1][1]) + rotation[2][2]) - 1.0;
let mut axis = if sine_twice > 0.0 {
let inverse = reciprocal(sine_twice, 2) * 1.0;
skew.map(|v| v * inverse)
} else {
[0.0; 4]
};
let ratio = sine_twice.mul_add(reciprocal(cosine_twice, 1), 0.0);
let mut angle = atan(ratio);
if cosine_twice < 0.0 {
angle += f32::from_bits(0x4049_0FDB).copysign(sine_twice);
}
if cosine_twice == 0.0 {
angle = f32::from_bits(0x3FC9_0FDB).copysign(sine_twice);
}
// Literal word 820CFC28, loaded with lvlx. Do not replace this with a
// conventional numerical tolerance.
if sine_twice <= f32::from_bits(0x0020_0000) && cosine_twice <= 0.0 {
axis = half_turn_axis(rotation);
}
(axis, angle)
}
///827B7400 selects the greatest diagonal. Its selected component is squared
/// before normalization (e.g. vmulfp at827B7488), unlike a common textbook
/// matrix-to-axis conversion. Preserve the native formula and tie ordering.
fn half_turn_axis(rotation: &Transform) -> Vector {
let selected = if rotation[0][0] > rotation[1][1] {
if rotation[0][0] > rotation[2][2] {
0
} else {
2
}
} else if rotation[1][1] > rotation[2][2] {
1
} else {
2
};
let mut axis = [0.0; 4];
for lane in 0..3 {
axis[lane] = if lane == selected {
let diagonal = 1.0 + rotation[selected][selected];
diagonal * diagonal
} else {
rotation[selected][lane] + rotation[lane][selected]
};
}
axis[3] = axis[0];
normalize(axis)
}
///Rodrigues construction in82BD34E0..3548. The native permutation duplicates
/// each column's X component into W; W is not a fabricated affine zero.
pub(super) fn axis_rotation(axis: Vector, angle: f32) -> Transform {
let (sine, cosine) = sin_cos(angle);
let complement = 1.0 - cosine;
let [x, y, z, _] = axis;
let sx = sine * x;
let sy = sine * y;
let sz = sine * z;
let tx = complement * x;
let ty = complement * y;
let tz = complement * z;
let xx = tx.mul_add(x, cosine);
let yy = ty.mul_add(y, cosine);
let zz = tz.mul_add(z, cosine);
let xy_positive = tx.mul_add(y, sz);
let xy_negative = ty * x - sz;
let xz_positive = tz.mul_add(x, sy);
let xz_negative = tx * z - sy;
let yz_positive = ty.mul_add(z, sx);
let yz_negative = tz * y - sx;
[
[xx, xy_positive, xz_negative, xx],
[xy_negative, yy, yz_positive, xy_negative],
[xz_positive, yz_negative, zz, xz_positive],
[0.0; 4],
]
}
///82BD3150 returns the remaining rotation angle as well as the blended frame.
pub(super) fn interpolate(a: &Transform, b: &Transform, weight: f32) -> (Transform, f32) {
if weight >= 1.0 {
return (*b, 0.0);
}
let mut relative = IDENTITY;
for column in 0..3 {
relative[column] = core::array::from_fn(|lane| {
let x = a[0][column] * b[0][lane];
let y = a[1][column].mul_add(b[1][lane], x);
a[2][column].mul_add(b[2][lane], y)
});
}
let (axis, angle) = rotation_axis_angle(&relative);
if weight <= 0.0 {
return (*a, angle);
}
let mut output = IDENTITY;
if angle < f32::from_bits(0x3D0E_FA35) {
for column in 0..3 {
output[column] = normalize(core::array::from_fn(|lane| {
(b[column][lane] - a[column][lane]).mul_add(weight, a[column][lane])
}));
}
} else {
let rotation = axis_rotation(axis, angle * weight);
for column in 0..3 {
output[column] = core::array::from_fn(|lane| {
let x = a[column][0] * rotation[0][lane];
let y = a[column][1].mul_add(rotation[1][lane], x);
a[column][2].mul_add(rotation[2][lane], y)
});
}
}
output[3] = core::array::from_fn(|lane| (b[3][lane] - a[3][lane]).mul_add(weight, a[3][lane]));
(output, angle - angle * weight)
}
///82E0A570 overrides the interpolation helper's translation, including at
/// endpoint weights. Preserve its weighted-sum rounding instead of delta lerp.
pub(super) fn interpolate_affine(a: &Transform, b: &Transform, weight: f32) -> Transform {
let translation =
core::array::from_fn(|lane| a[3][lane].mul_add(1.0 - weight, b[3][lane] * weight));
let (mut output, _) = interpolate(a, b, weight);
output[3] = translation;
output
}
/// General inverse expanded at8296FB50..FD14 and SkeletonData Init82BD6E9C.
/// The transpose permutation retains the second cofactor in the fourth lane.
pub fn inverse_affine(frame: &Transform) -> Transform {
let cofactors = [
cross(frame[1], frame[2]),
cross(frame[2], frame[0]),
cross(frame[0], frame[1]),
];
let inverse = reciprocal(dot3(frame[0], cofactors[0]), 2);
let mut result: Transform = core::array::from_fn(|column| {
if column == 3 {
[0.0; 4]
} else {
[
cofactors[0][column] * inverse,
cofactors[1][column] * inverse,
cofactors[2][column] * inverse,
cofactors[1][column] * inverse,
]
}
});
let negative = frame[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000));
result[3] = core::array::from_fn(|lane| {
let x = negative[0] * result[0][lane];
let y = negative[1].mul_add(result[1][lane], x);
negative[2].mul_add(result[2][lane], y)
});
result
}
@@ -0,0 +1,17 @@
//! Native skeleton IK, TU3 GeneralUpdate82BDCA38 and its six update stages.
pub mod blend;
pub mod contact;
pub mod drive;
pub mod external;
mod physical_solve;
pub mod post_contact;
pub mod post_physics;
mod math;
pub use math::inverse_affine;
pub mod settings;
pub mod state;
pub mod status;
pub mod transforms;
pub mod two_bone;
#[cfg(test)]
mod tests;
@@ -0,0 +1,72 @@
//! SkeletonIK::SolvePhysical82BF1480. Physical joint centers, not drive targets.
use super::{
drive::{Geometry, line_mapping, normalize_safe},
math::cross,
two_bone::{self, AngleLimits, SolveResult},
};
use crate::physics::{
skeleton_animation_record::{compose_affine, transform_point},
skeleton_body::SkeletonBody,
};
pub(super) fn solve(
body: &mut SkeletonBody,
geometry: &Geometry,
end_part: usize,
offset: [f32; 4],
) -> bool {
let Some(middle_part) = geometry.parents[end_part] else {
return false;
};
let Some(root_part) = geometry.parents[middle_part] else {
return false;
};
let point = |part: usize| {
transform_point(
&body.record.pose[part],
geometry.inverse_part_frames[part][3],
)
};
let [root, middle, end] = [point(root_part), point(middle_part), point(end_part)];
let mut target = core::array::from_fn(|i| end[i] + offset[i]);
let mut solved_middle = [0.0; 4];
//Literal0 and180, no original-angle override and recursion budget1.
//The source requires return0; its extended/invalid paths BOTH reject.
if two_bone::solve(
root,
middle,
end,
&mut solved_middle,
&mut target,
AngleLimits {
minimum_degrees: 0.0,
maximum_degrees: 180.0,
},
false,
1,
) != SolveResult::Solved
{
return false;
}
let original_normal = cross(sub(root, middle), sub(end, middle));
let solved_normal = cross(sub(root, solved_middle), sub(target, solved_middle));
let normal = normalize_safe(core::array::from_fn(|i| {
original_normal[i] + solved_normal[i]
}));
let Some(mid_map) = line_mapping(middle, end, normal, solved_middle, target, normal) else {
return false;
};
let Some(root_map) = line_mapping(root, middle, normal, root, solved_middle, normal) else {
return false;
};
let middle_frame = compose_affine(&mid_map, &body.record.pose[middle_part]);
let root_frame = compose_affine(&root_map, &body.record.pose[root_part]);
//Unlike pre-physics drive solving, no extra orthonormalization precedes
//the setter. The live body normalizes; the record keeps these frames.
body.set_part_transform(middle_part, middle_frame);
body.set_part_transform(root_part, root_frame);
true
}
fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
core::array::from_fn(|i| a[i] - b[i])
}
@@ -0,0 +1,113 @@
//! SkeletonIK::CheckFootAgainstBoard82BF0D70, including raised deck ends.
use super::{math::reciprocal, settings::Settings, transforms::inverse_rigid};
use crate::physics::{
native_arithmetic::vector_min,
skeleton_animation_record::{AnimationPartTransform as Transform, transform_point},
};
#[derive(Clone, Copy, Debug)]
pub struct SettingsPost {
///physics_animation/default layout780.
pub minimum_board_up: f32,
///Global212's authored layout412/416, selected by the wipeout argument.
pub wipeout_height: f32,
pub riding_height: f32,
}
pub(super) fn target(
board: &Transform,
foot: [f32; 4],
adjacent: [f32; 4],
animated_board_position: [f32; 4],
wipeout: bool,
settings: &Settings,
post: &SettingsPost,
) -> Option<[f32; 4]> {
let inverse = inverse_rigid(board);
let local_foot = transform_point(&inverse, foot);
let local_adjacent = transform_point(&inverse, adjacent);
let mut midpoint: [f32; 4] =
core::array::from_fn(|i| (local_foot[i] + local_adjacent[i]) * 0.5);
//vrlimi mask4 preserves the foot's Y; X/Z test both foot and midpoint.
midpoint[1] = local_foot[1];
let x = vector_min(local_foot[0].abs(), midpoint[0].abs());
let z = vector_min(local_foot[2].abs(), midpoint[2].abs());
if x >= settings.deck_half_width + settings.post_ik_padding[0]
|| z >= settings.deck_total_half_length + settings.post_ik_padding[2]
{
return None;
}
let mut height = if wipeout {
post.wipeout_height
} else {
post.riding_height
};
if z > settings.deck_half_length {
let distance = z - settings.deck_half_length;
let end = settings.deck_total_half_length - settings.deck_half_length;
let distance = if end - distance >= 0.0 { distance } else { end };
//Constructor82BED418 caches tan(clamp(authored degrees*.01745...)).
let angle = settings.deck_front_angle_degrees * f32::from_bits(0x3C8E_FA35);
let angle = if 0.01 - angle >= 0.0 { 0.01 } else { angle };
let maximum = f32::from_bits(0x3FC7_C82D);
let angle = if maximum - angle >= 0.0 {
angle
} else {
maximum
};
height = distance.mul_add(tangent(angle), height);
}
if midpoint[1] >= height || midpoint[1] <= height - f32::from_bits(0x3E99_999A) {
return None;
}
let mut result = animated_board_position;
result[1] = height;
Some(transform_point(board, result))
}
///Scalar constructor path through original8252D980. Its argument is already
///clamped to [.01,1.5607964]; coefficients come from822FE3C0..E0.
fn tangent(angle: f32) -> f32 {
let quadrant = (angle * f32::from_bits(0x3F22_F983)).round_ties_even();
let reduced = (-quadrant).mul_add(f32::from_bits(0x3FC9_0FDB), angle);
let reduced = (-quadrant).mul_add(f32::from_bits(0x2E85_A309), reduced);
let squared = reduced * reduced;
let denominator = squared.mul_add(f32::from_bits(0x3505_BBA8), f32::from_bits(0xB9A3_7B25));
let denominator = squared.mul_add(denominator, f32::from_bits(0x3CD2_3CF5));
let denominator = squared.mul_add(denominator, f32::from_bits(0xBEEE_F582));
let denominator = squared.mul_add(denominator, 1.0);
let numerator = squared.mul_add(f32::from_bits(0xB795_D5B9), f32::from_bits(0x3B60_7415));
let numerator = squared.mul_add(numerator, f32::from_bits(0xBE08_95AF));
let numerator = reduced.mul_add(squared * numerator, reduced);
//vcmpbfp's two high result bits select corresponding sign/exponent bits.
//The bounded constructor argument preserves these bits in both operands;
//retain the actual bit-select rather than replacing it by a boolean branch.
let bound = f32::from_bits(0x3980_0000);
let mask = (if reduced > bound { 0x8000_0000 } else { 0 })
| (if reduced < -bound { 0x4000_0000 } else { 0 });
let denominator = f32::from_bits((denominator.to_bits() & !mask) | (1.0f32.to_bits() & mask));
let numerator = f32::from_bits((numerator.to_bits() & !mask) | (reduced.to_bits() & mask));
if quadrant.abs() as i32 & 1 == 0 {
numerator * reciprocal(denominator, 2)
} else {
denominator * reciprocal(-numerator, 2)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn original_tangent_rational_spans_both_constructor_quadrants() {
//Independent mathematical values test argument reduction and rational
//operand order, without claiming original hardware estimate parity.
for angle in [0.01f32, 0.25, 0.75, 1.0, 1.5607964] {
let actual = tangent(angle);
let reference = f64::from(angle).tan() as f32;
assert!(
(actual - reference).abs() < 3e-5 * reference.abs().max(1.0),
"{angle}: {actual} vs {reference}"
);
}
}
}
@@ -0,0 +1,159 @@
//! PostWipeoutCheck's feet82BF0680, hands82BF0C28 and external82BEDC10.
use super::{
drive::Geometry,
physical_solve,
post_contact::{self, SettingsPost},
settings::Settings,
state::{FootIkState, LIMBS},
};
use crate::physics::{
native_arithmetic::dot3,
skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine},
skeleton_body::SkeletonBody,
};
pub struct Input<'a> {
pub state_id: u32,
pub category_id: u32,
///Actual BoardBody868, used by original state200/201 hand/foot gates.
pub board_body_flag_868: bool,
pub wipeout: bool,
pub flags_2468: u32,
pub flags_2484: u32,
///Processed2664 and2520, read only when2468bit18 is set.
pub value_2664: f32,
pub state_2520: u32,
pub world_to_animation: &'a Transform,
///Actual board GetPartTransform, replaced by wobble-adjusted record when sampled.
pub board: &'a Transform,
}
pub fn update(
state: &mut FootIkState,
body: &mut SkeletonBody,
geometry: &Geometry,
settings: &Settings,
post: &SettingsPost,
input: Input<'_>,
) -> [bool; 4] {
let mut updated = [false; 4];
if input.state_id != 702 {
let airborne = matches!(input.state_id, 200 | 201);
if (!airborne || input.board_body_flag_868) && input.category_id != 500 {
feet(state, body, geometry, settings, post, &input, &mut updated);
}
if airborne && input.board_body_flag_868 {
for limb in 2..4 {
if state.frames[limb].within_contact_bounds {
state.frames[limb].world =
compose_affine(input.board, &state.frames[limb].board);
let part = LIMBS[limb].part;
let offset = sub(state.frames[limb].world[3], body.record.pose[part][3]);
if physical_solve::solve(body, geometry, part, offset) {
body.set_part_transform(part, state.frames[limb].world);
updated[limb] = true;
}
}
}
}
}
//Always run the external-target pass, including state702.
for limb in 0..2 {
if !(state.limbs[limb].external_blend <= 0.0) {
let binding = LIMBS[limb];
let adjacent = binding.parent_part.unwrap();
let offset = sub(
state.frames[limb].parent_world[3],
body.record.pose[adjacent][3],
);
if physical_solve::solve(body, geometry, adjacent, offset) {
body.set_part_transform(binding.part, state.frames[limb].world);
body.set_part_transform(adjacent, state.frames[limb].parent_world);
updated[limb] = true;
}
}
}
for limb in 0..4 {
state.external_targets[limb].normal_set = false;
state.limbs[limb].external_target_set = false;
state.limbs[limb].local_target_set = false;
}
updated
}
fn feet(
state: &mut FootIkState,
body: &mut SkeletonBody,
geometry: &Geometry,
settings: &Settings,
post: &SettingsPost,
input: &Input<'_>,
updated: &mut [bool; 4],
) {
let mut effective_board = *input.board;
if input.flags_2484 & (1 << 21) != 0 {
effective_board[0] = effective_board[0].map(|v| v * -1.0);
effective_board[1] = effective_board[1].map(|v| v * -1.0);
}
let up = effective_board[1];
let up_y = up[0] * input.world_to_animation[0][1];
let up_y = up[1].mul_add(input.world_to_animation[1][1], up_y);
let up_y = up[2].mul_add(input.world_to_animation[2][1], up_y);
let constrained = input.flags_2468 & (1 << 18) != 0;
if input.flags_2484 & (1 << 20) != 0
|| post.minimum_board_up > up_y
|| constrained && input.value_2664 > f32::from_bits(0x3D23_D70A)
|| constrained && input.state_2520 == 500
{
return;
}
for limb in 0..2 {
let binding = LIMBS[limb];
let adjacent = binding.parent_part.unwrap();
if state.frames[limb].within_contact_bounds && !input.wipeout {
state.frames[limb].world = compose_affine(input.board, &state.frames[limb].board);
state.frames[limb].parent_world =
compose_affine(input.board, &state.frames[limb].parent_board);
let offset = sub(
state.frames[limb].parent_world[3],
body.record.pose[adjacent][3],
);
if physical_solve::solve(body, geometry, adjacent, offset) {
body.set_part_transform(binding.part, state.frames[limb].world);
body.set_part_transform(adjacent, state.frames[limb].parent_world);
updated[limb] = true;
}
} else if !state.limbs[limb].external_target_set {
let mut foot = body.record.pose[binding.part];
if dot3(foot[1], up) <= 0.0 {
continue;
}
let Some(target) = post_contact::target(
&effective_board,
foot[3],
body.record.pose[adjacent][3],
state.limbs[limb].part_position,
input.wipeout,
settings,
post,
) else {
continue;
};
let distance = dot3(up, sub(target, foot[3]));
let offset = up.map(|v| v * distance);
if physical_solve::solve(body, geometry, adjacent, offset) {
foot[3] = target;
body.set_part_transform(binding.part, foot);
let mut adjacent_frame = body.record.pose[adjacent];
for lane in 0..4 {
adjacent_frame[3][lane] += offset[lane];
}
body.set_part_transform(adjacent, adjacent_frame);
updated[limb] = true;
}
}
}
}
fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
core::array::from_fn(|i| a[i] - b[i])
}
@@ -0,0 +1,21 @@
//! Authored IK parameters, resolved against the stock class layout.
use super::status::BlendSettings;
use super::two_bone::AngleLimits;
#[derive(Clone, Copy, Debug)]
pub struct Settings {
/// physics_animation/default layout876/880, consumed by82BF0FA8.
pub angle_limits: AngleLimits,
pub blend: BlendSettings,
/// physics_skeletonik layout0/48/64/80.
pub post_ik_padding: [f32; 4],
pub contact_bounds: [f32; 4],
pub foot_on_deck_padding: [f32; 4],
pub wipeout_feet_offset: f32,
///82BED418 retrieves physicsdeck hashes EE1FD008/1DCB828B/932190E7.
pub deck_half_width: f32,
pub deck_half_length: f32,
pub deck_total_half_length: f32,
/// physicsdeck.DeckFrontEndAngle, hashF9B732CA; retained authored degrees.
pub deck_front_angle_degrees: f32,
}
@@ -0,0 +1,174 @@
//! Persistent SkeletonIK state and the GeneralUpdate82BDCA38 stage order.
use super::{
blend,
contact::{ContactInput, ContactState},
drive::{self, Geometry},
external::{self, ExternalTarget},
settings::Settings,
status::{self, LimbStatus, Mode},
transforms::{self, LimbBinding, LimbFrames},
};
use crate::physics::skeleton_animation_record::AnimationPartTransform as Transform;
///82BED688 binds the toe targets to foot/toe volumes and the hand targets to
///their hand volumes. The optional second volume is distinct from chain parents.
pub const LIMBS: [LimbBinding; 4] = [
LimbBinding {
part: 15,
parent_part: Some(16),
},
LimbBinding {
part: 19,
parent_part: Some(20),
},
LimbBinding {
part: 3,
parent_part: None,
},
LimbBinding {
part: 7,
parent_part: None,
},
];
#[derive(Clone, Debug)]
pub struct FootIkState {
pub limbs: [LimbStatus; 4],
pub frames: [LimbFrames; 4],
pub external_targets: [ExternalTarget; 4],
pub contacts: ContactState,
feet_enabled: bool,
}
impl Default for FootIkState {
fn default() -> Self {
//82BED780 resets the retained histories and enables feet; it is not
//the wipeout-entry solve82BF1B90, which disables feet after its update.
Self {
limbs: [LimbStatus::default(); 4],
frames: [LimbFrames::default(); 4],
external_targets: [ExternalTarget::default(); 4],
contacts: ContactState::default(),
feet_enabled: true,
}
}
}
pub struct UpdateInput<'a> {
pub flags_2468: u32,
pub flags_2472: u32,
pub flags_2480: u32,
/// DataIn32/96; both are actual Skeleton root transforms.
pub animation_to_world: &'a Transform,
pub world_to_animation: &'a Transform,
/// DataIn160, from Skeleton12496. This is independently produced from
///DataIn352 (Skeleton12304) used by foot contact correction.
pub physical_board: &'a Transform,
pub contact_board: &'a Transform,
pub inverse_contact_board: &'a Transform,
/// SkeletonData animation record: physical volume frames after board offset.
pub animation: &'a [Transform; 24],
/// ProcessData14160: original selected globals, before volume composition.
pub original_animation: &'a [Transform; 24],
/// Actual board-parented targets after their type5 volume composition.
pub targets: &'a [Transform; 4],
pub hips_world_position: [f32; 4],
pub contact_bone: usize,
pub foot_bones: [usize; 2],
pub current_contacts: [Option<[f32; 4]>; 2],
}
impl FootIkState {
pub fn reset(&mut self) {
*self = Self::default();
}
/// Ground::Enter writes (*(Skeleton+16432))+464 directly.
pub fn enable_feet(&mut self, enabled: bool) {
self.feet_enabled = enabled;
}
pub fn feet_enabled(&self) -> bool {
self.feet_enabled
}
/// Ground collision handling can also set the per-update3185 flag.
pub fn mark_support_failed_this_update(&mut self) {
self.contacts.support_failed_this_update = true;
}
/// The mutable output is Skeleton12624 consumed by SkeletonDrives. The
///original animation and collision-volume record must remain separate.
pub fn update(
&mut self,
input: UpdateInput<'_>,
geometry: &Geometry,
settings: &Settings,
drives: &mut [Transform; 24],
) -> [bool; 4] {
for limb in 0..4 {
self.frames[limb].target = input.targets[limb];
}
status::update_modes(&mut self.limbs, self.feet_enabled, input.flags_2472);
status::update_blends(
&mut self.limbs,
settings.deck_half_width,
settings.deck_total_half_length,
input.flags_2480,
&settings.blend,
);
let inverse_animation_board = transforms::inverse_rigid(&input.animation[0]);
for limb in 0..4 {
self.frames[limb].within_contact_bounds = false;
if matches!(self.limbs[limb].mode, Mode::External | Mode::Local) {
external::update(
&mut self.external_targets[limb],
&mut self.limbs[limb],
&mut self.frames[limb],
LIMBS[limb],
input.animation,
input.animation_to_world,
input.world_to_animation,
);
}
transforms::prepare_animation_target(
&mut self.limbs[limb],
&mut self.frames[limb],
LIMBS[limb],
input.animation,
input.animation_to_world,
&inverse_animation_board,
settings.contact_bounds,
);
}
blend::update(
&self.limbs,
&mut self.frames,
&LIMBS,
input.physical_board,
input.animation_to_world,
&input.animation[0],
);
self.contacts.update(
&self.limbs,
&mut self.frames,
ContactInput {
flags_2468: input.flags_2468,
contact_bone: input.contact_bone,
foot_bones: input.foot_bones,
hips_world_position: input.hips_world_position,
inverse_board: input.inverse_contact_board,
board: input.contact_board,
current_contacts: input.current_contacts,
},
);
drive::update(
&self.limbs,
&mut self.frames,
&LIMBS,
geometry,
input.original_animation,
drives,
input.world_to_animation,
settings.angle_limits,
)
}
}
@@ -0,0 +1,194 @@
//! Limb mode transitions82BEDCF8 and blend weights82BEEC00.
//! Named S2 UpdateStatus/UpdateBlendValues corroborate field identities;
//! TU3 flags and cached settings determine the implementation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Disabled,
OnDeck,
External,
Local,
}
#[derive(Clone, Copy, Debug)]
pub struct LimbStatus {
pub mode: Mode,
/// SkeletonIK+468+4*limb.
pub board_blend: f32,
/// SkeletonIK+484+4*limb.
pub external_blend: f32,
pub target_blend: f32,
pub external_target_set: bool,
pub local_target_set: bool,
pub external_target_local_delta: [f32; 4],
/// CalculateInitialPartTransforms retains the target in animation-deck space.
pub part_position: [f32; 4],
}
impl Default for LimbStatus {
fn default() -> Self {
//82BED780 resets mode, both weights, target blend, target flags and
//the complete local delta/part-position vectors to zero.
Self {
mode: Mode::Disabled,
board_blend: 0.0,
external_blend: 0.0,
target_blend: 0.0,
external_target_set: false,
local_target_set: false,
external_target_local_delta: [0.0; 4],
part_position: [0.0; 4],
}
}
}
/// All four limbs in native order: left foot, right foot, left hand, right hand.
/// Foot enable is SkeletonIK464; reset82BED780 initializes it to true.
pub fn update_modes(limbs: &mut [LimbStatus; 4], feet_enabled: bool, flags_2472: u32) {
let enabled = [
feet_enabled,
feet_enabled,
flags_2472 & 0x80 != 0,
flags_2472 & 0x100 != 0,
];
for (limb, enabled) in limbs.iter_mut().zip(enabled) {
let fallback = if enabled {
Mode::OnDeck
} else {
Mode::Disabled
};
match limb.mode {
Mode::Disabled => {
limb.board_blend = 0.0;
if limb.external_target_set {
limb.mode = Mode::External;
limb.external_blend = limb.target_blend;
} else if limb.local_target_set {
limb.mode = Mode::Local;
limb.external_blend = limb.target_blend;
limb.external_target_local_delta = [0.0; 4];
} else {
limb.mode = fallback;
limb.external_blend = 0.0;
}
}
Mode::OnDeck => {
if limb.external_target_set {
limb.mode = Mode::External;
limb.external_blend = limb.target_blend;
} else if limb.local_target_set {
limb.mode = Mode::Local;
limb.external_blend = limb.target_blend;
limb.external_target_local_delta = [0.0; 4];
} else if limb.board_blend == 0.0 {
limb.mode = fallback;
limb.external_blend = 0.0;
}
}
Mode::External => {
if !limb.external_target_set {
limb.mode = Mode::Local;
}
}
Mode::Local => {
if limb.external_target_set {
limb.mode = Mode::External;
limb.external_blend = limb.target_blend;
}
// This is a second independent test, even after switching to
//External above. Preserve the native zero-target-blend case.
if !limb.local_target_set && limb.external_blend == 0.0 {
limb.mode = fallback;
limb.external_target_local_delta = [0.0; 4];
}
}
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct BlendSettings {
/// physics_skeletonik cached layout16/32.
pub hand_inner_padding: [f32; 4],
pub hand_outer_padding: [f32; 4],
/// Cached layout84 and88; native increments per update, without dt.
pub external_blend_step: f32,
pub board_blend_step: f32,
}
/// Board dimensions are the actual constructor-derived half width and total
/// half length, not the board's visual bounds or cached generic dimensions.
pub fn update_blends(
limbs: &mut [LimbStatus; 4],
deck_half_width: f32,
deck_total_half_length: f32,
flags_2480: u32,
settings: &BlendSettings,
) {
let base = [deck_half_width, 0.0, deck_total_half_length, 0.0];
let inner: [f32; 4] = core::array::from_fn(|i| base[i] + settings.hand_inner_padding[i]);
let outer: [f32; 4] = core::array::from_fn(|i| inner[i] + settings.hand_outer_padding[i]);
let step = settings.board_blend_step;
for (index, limb) in limbs.iter_mut().enumerate() {
match limb.mode {
Mode::Disabled => {}
Mode::External => {
limb.external_blend = limb.target_blend;
limb.board_blend = positive(limb.board_blend - step);
}
Mode::Local => {
limb.external_blend = if limb.local_target_set {
1.0
} else {
positive(limb.external_blend - settings.external_blend_step)
};
limb.board_blend = positive(limb.board_blend - step);
}
Mode::OnDeck if index < 2 => {
let target = if flags_2480 & 0x0800_0000 != 0 {
0.0
} else {
1.0
};
approach(&mut limb.board_blend, target, step);
}
Mode::OnDeck => {
let p = limb.part_position.map(f32::abs);
if limb.board_blend > 0.0 || p[0] < outer[0] && p[1] < outer[1] && p[2] < outer[2] {
let mut target = 1.0;
// Native order X, Z, Y; fsel preserves equality and NaNs.
for axis in [0, 2, 1] {
if p[axis] > inner[axis] {
let candidate =
1.0 - (p[axis] - inner[axis]) / settings.hand_outer_padding[axis];
if axis == 0 {
target = candidate;
} else if axis == 2 {
target = select(target - candidate, candidate, target);
} else {
target = select(candidate - target, target, candidate);
}
}
}
target = positive(target);
target = select(1.0 - target, target, 1.0);
approach(&mut limb.board_blend, target, step);
}
}
}
}
}
fn select(condition: f32, nonnegative: f32, negative: f32) -> f32 {
if condition >= 0.0 {
nonnegative
} else {
negative
}
}
fn positive(value: f32) -> f32 {
select(-value, 0.0, value)
}
fn approach(value: &mut f32, target: f32, step: f32) {
let delta = target - *value;
let delta = select(-step - delta, -step, delta);
*value = select(step - delta, delta, step) + *value;
}
@@ -0,0 +1,94 @@
use super::{
contact::{ContactInput, ContactState},
math::{interpolate, inverse_affine},
status::{LimbStatus, Mode},
transforms::LimbFrames,
two_bone::{self, AngleLimits, SolveResult},
};
use crate::physics::skeleton_animation_record::{IDENTITY, transform_point};
#[test]
fn inverse_preserves_points_for_scaled_and_sheared_authored_frames() {
let frame = [
[2.0, 0.0, 0.0, 0.0],
[0.5, 3.0, 0.0, 0.0],
[0.0, 0.25, 4.0, 0.0],
[7.0, -2.0, 5.0, 0.0],
];
let point = [0.3, -0.7, 1.2, 0.0];
let recovered = transform_point(&inverse_affine(&frame), transform_point(&frame, point));
for lane in 0..3 {
assert!((recovered[lane] - point[lane]).abs() < 2e-6);
}
}
#[test]
fn frame_interpolation_uses_rotation_and_translation_at_the_same_weight() {
let mut target = IDENTITY;
target[0] = [0.0, 1.0, 0.0, 0.0];
target[1] = [-1.0, 0.0, 0.0, 0.0];
target[3] = [8.0, 2.0, -4.0, 0.0];
let (half, remaining) = interpolate(&IDENTITY, &target, 0.5);
assert!((half[0][0] - core::f32::consts::FRAC_1_SQRT_2).abs() < 2e-6);
assert!((half[0][1] - core::f32::consts::FRAC_1_SQRT_2).abs() < 2e-6);
assert_eq!(&half[3][..3], &[4.0, 1.0, -2.0]);
assert!((remaining - core::f32::consts::FRAC_PI_4).abs() < 2e-6);
assert_eq!(interpolate(&IDENTITY, &target, 1.0), (target, 0.0));
}
#[test]
fn reachable_two_bone_target_keeps_both_segment_lengths() {
let root = [0.0; 4];
let middle = [0.3, 0.4, 0.0, 0.0];
let end = [0.6, 0.0, 0.0, 0.0];
let mut target = [0.7, 0.0, 0.0, 0.0];
let mut solved = [0.0; 4];
let result = two_bone::solve(
root,
middle,
end,
&mut solved,
&mut target,
AngleLimits {
minimum_degrees: 5.0,
maximum_degrees: 175.0,
},
true,
2,
);
assert_eq!(result, SolveResult::Solved);
assert_eq!(&target[..3], &[0.7, 0.0, 0.0]);
let distance = |a: [f32; 4], b: [f32; 4]| {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
};
assert!((distance(root, solved) - 0.5).abs() < 2e-5, "{solved:?}");
assert!((distance(solved, target) - 0.5).abs() < 2e-5, "{solved:?}");
}
#[test]
fn foot_queries_affect_the_following_update_and_loss_sets_support_history() {
let mut contacts = ContactState::default();
let mut statuses = [LimbStatus::default(); 4];
statuses[0].mode = Mode::OnDeck;
let mut frames = [LimbFrames::default(); 4];
let input = |hit| ContactInput {
flags_2468: 0x0200_0000,
contact_bone: 10,
foot_bones: [10, 20],
hips_world_position: [0.0; 4],
inverse_board: &IDENTITY,
board: &IDENTITY,
current_contacts: [hit, None],
};
contacts.update(&statuses, &mut frames, input(None));
assert!(!contacts.support_failed_this_update);
assert_eq!(contacts.feet[0].query_state, 2);
contacts.update(&statuses, &mut frames, input(Some([0.0; 4])));
assert!(contacts.support_failed_this_update);
assert!(contacts.support_failed);
assert_eq!(contacts.feet[0].query_state, 1);
statuses[0].mode = Mode::Disabled;
contacts.update(&statuses, &mut frames, input(None));
assert!(!contacts.support_failed_this_update);
assert!(contacts.support_failed);
}
@@ -0,0 +1,97 @@
//! Animated portion of CalculateInitialPartTransforms82BEDF08.
use super::status::{LimbStatus, Mode};
use crate::physics::skeleton_animation_record::{
AnimationPartTransform as Transform, IDENTITY, compose_affine,
};
#[derive(Clone, Copy, Debug)]
pub struct LimbBinding {
/// SkeletonIK1120: the physics volume driven by this target.
pub part: usize,
/// SkeletonIK1136: optional adjacent volume receiving the same adjustment.
pub parent_part: Option<usize>,
}
#[derive(Clone, Copy, Debug)]
pub struct LimbFrames {
/// SkeletonIK864, from the actual reparented animation target.
pub target: Transform,
/// SkeletonIK1152; animated world frame, then blended/contact-adjusted.
pub world: Transform,
/// SkeletonIK1408: externally supplied target in world space.
pub external_world: Transform,
/// SkeletonIK1664: target relative to the animated board.
pub board: Transform,
/// SkeletonIK2240, optional adjacent volume in world space.
pub parent_world: Transform,
/// SkeletonIK2496: adjacent volume's external target.
pub external_parent_world: Transform,
/// SkeletonIK2752: adjacent volume relative to the animated board.
pub parent_board: Transform,
/// SkeletonIK3120: animated target lies within the cached contact box.
pub within_contact_bounds: bool,
}
impl Default for LimbFrames {
fn default() -> Self {
//82BED780 initializes every retained affine frame to identity.
Self {
target: IDENTITY,
world: IDENTITY,
external_world: IDENTITY,
board: IDENTITY,
parent_world: IDENTITY,
external_parent_world: IDENTITY,
parent_board: IDENTITY,
within_contact_bounds: false,
}
}
}
/// Prepare the animated frames after this limb's CalculateExternalIKOffset
/// stage. External/local modes retain their separately calculated frames.
/// The full controller calls both stages in native limb order.
pub fn prepare_animation_target(
status: &mut LimbStatus,
frames: &mut LimbFrames,
binding: LimbBinding,
animation: &[Transform; 24],
animation_to_world: &Transform,
inverse_animation_board: &Transform,
contact_bounds: [f32; 4],
) {
if status.mode != Mode::OnDeck && !(status.board_blend > 0.0) && status.external_blend >= 1.0 {
return;
}
let animated = &animation[binding.part];
frames.board = compose_affine(inverse_animation_board, &frames.target);
status.part_position = frames.board[3];
frames.world = compose_affine(animation_to_world, animated);
// Native comparisons reject only a strict bound violation; NaNs do not
// become a host-side exclusion. The fourth stored lane is excluded.
frames.within_contact_bounds = (0..3).all(|i| {
!(status.part_position[i] > contact_bounds[i]
|| -contact_bounds[i] > status.part_position[i])
});
if let Some(parent) = binding.parent_part {
let relative = compose_affine(&inverse_rigid(animated), &animation[parent]);
frames.parent_board = compose_affine(&frames.board, &relative);
frames.parent_world = compose_affine(&frames.world, &relative);
}
}
///82BEDF5C..DFEC and E1B4..E260: rigid inverse, transpose XYZ and zero W;
/// translation uses negative position with Z multiply, Y FMA, then X FMA.
/// This deliberately does not introduce a general scale/shear inverse.
pub fn inverse_rigid(source: &Transform) -> Transform {
let mut result = IDENTITY;
for axis in 0..3 {
result[axis] = [source[0][axis], source[1][axis], source[2][axis], 0.0];
}
let translation = source[3].map(|v| 0.0 - v);
result[3] = core::array::from_fn(|i| {
let z = translation[2] * result[2][i];
let y = translation[1].mul_add(result[1][i], z);
translation[0].mul_add(result[0][i], y)
});
result
}
@@ -0,0 +1,202 @@
//! Math::_TwoBoneIKSolve8296F8C0, TU3 complete reachable/recursive/invalid path.
//! Plane construction uses the native general inverse, not a rigid transpose.
use super::math::{Vector, cross, inverse_affine, length, normalize, reciprocal};
use crate::{
physics::{
native_arithmetic::{dot3, vector_max, vector_min},
skeleton_animation_record::{AnimationPartTransform as Transform, transform_point},
},
trigonometry::{acos, cos, sin_cos},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SolveResult {
Solved,
Extended,
Invalid,
}
#[derive(Clone, Copy, Debug)]
pub struct AngleLimits {
pub minimum_degrees: f32,
pub maximum_degrees: f32,
}
const EPSILON: f32 = f32::from_bits(0x3A03_126F); //830BD4B0 <-821A0318
const TO_DEGREES: f32 = f32::from_bits(0x4265_2EE1); //830BD390
const TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); //830BD360
pub fn solve(
root: Vector,
middle: Vector,
end: Vector,
solved_middle: &mut Vector,
target: &mut Vector,
limits: AngleLimits,
override_maximum_with_original: bool,
recursion_budget: u32,
) -> SolveResult {
if recursion_budget == 0 {
*target = end;
*solved_middle = middle;
return SolveResult::Invalid;
}
let upper = subtract(middle, root);
let lower = subtract(end, middle);
let upper_length = length(upper);
let lower_length = length(lower);
if EPSILON > upper_length || EPSILON > lower_length {
*solved_middle = middle;
return SolveResult::Invalid;
}
let requested_delta = subtract(*target, root);
let forward = normalize(subtract(end, root));
let normal = normalize(cross(upper, lower));
let original_basis = [forward, cross(forward, normal), normal, root];
let target_forward = normalize(requested_delta);
let target_lateral = cross(target_forward, normal);
let target_normal = cross(target_lateral, target_forward);
let target_basis = [target_forward, target_lateral, target_normal, root];
let original_inverse = inverse_affine(&original_basis);
let target_inverse = inverse_affine(&target_basis);
// Each point is transformed separately, retaining the original origin's
// rounding; do not replace these with pre-subtracted vectors.
let local_root = transform_point(&original_inverse, root);
let local_middle = transform_point(&original_inverse, middle);
let local_end = transform_point(&original_inverse, end);
let local_target = transform_point(&target_inverse, *target);
let target_delta = subtract(local_target, local_root);
let planar_squared =
target_delta[0].mul_add(target_delta[0], target_delta[1] * target_delta[1]);
let distance = square_root(planar_squared);
let upper_squared = upper_length * upper_length;
let lower_squared = lower_length * lower_length;
let twice_upper = 2.0 * upper_length;
let twice_product = twice_upper * lower_length;
let sum_squared = upper_squared + lower_squared;
let negative_lower = lower.map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000));
let negative_length = f32::from_bits(lower_length.to_bits() ^ 0x8000_0000);
let original_angle =
acos(reciprocal(upper_length * negative_length, 2) * dot3(upper, negative_lower))
* TO_DEGREES;
let mut maximum = limits.maximum_degrees;
if original_angle > maximum && override_maximum_with_original {
maximum = original_angle;
}
let minimum_distance =
square_root(sum_squared - twice_product * cos(limits.minimum_degrees * TO_RADIANS));
let maximum_distance = square_root(sum_squared - twice_product * cos(maximum * TO_RADIANS));
if minimum_distance > distance || distance > maximum_distance {
if distance > upper_length + lower_length && maximum == 180.0 {
let direction = normalize(target_delta);
let middle_local = scaled_add(direction, upper_length, local_root);
let end_local = scaled_add(direction, lower_length, middle_local);
*solved_middle = transform_point(&target_basis, middle_local);
*target = transform_point(&target_basis, end_local);
return SolveResult::Extended;
}
let distance = if distance > maximum_distance {
maximum_distance - EPSILON
} else {
minimum_distance + EPSILON
};
*target = scaled_add(normalize(requested_delta), distance, root);
return solve(
root,
middle,
end,
solved_middle,
target,
limits,
override_maximum_with_original,
recursion_budget - 1,
);
}
let cosine = clamp_native_cosine(reciprocal(twice_product, 2) * (sum_squared - planar_squared));
let interior_angle = acos(cosine) * TO_DEGREES;
if limits.minimum_degrees >= interior_angle {
*target = scaled_add(normalize(requested_delta), minimum_distance + EPSILON, root);
return solve(
root,
middle,
end,
solved_middle,
target,
limits,
override_maximum_with_original,
recursion_budget - 1,
);
}
let mut target_angle = acos(clamp_native_cosine(
reciprocal(distance, 2) * target_delta[0],
));
if local_middle[1] < 0.0 && local_target[1] > 0.0
|| local_middle[1] > 0.0 && local_target[1] < 0.0
{
target_angle *= -1.0;
}
// The target heading is an addend *inside* this acos in the TU3 body
// (vmaddfp82970088). Preserve it rather than fitting a textbook IK formula.
let ratio = reciprocal(twice_upper * distance, 2).mul_add(
(upper_squared + planar_squared) - lower_squared,
target_angle,
);
let angle = acos(clamp_native_cosine(ratio));
let original_delta = subtract(local_end, local_root);
let positive = candidate(
original_delta,
angle,
upper_length,
local_root,
&target_basis,
);
let negative = candidate(
original_delta,
f32::from_bits(0x40C9_0FDB) - angle,
upper_length,
local_root,
&target_basis,
);
*solved_middle = if local_end[1] > local_middle[1] {
negative
} else {
positive
};
SolveResult::Solved
}
///The source uses830BD310=0 and830BD430=1 for these clamps. The lower
///constant is not minus one; its initializer82F82718 loads82165A10.
fn clamp_native_cosine(value: f32) -> f32 {
vector_min(1.0, vector_max(0.0, value))
}
fn square_root(squared: f32) -> f32 {
let inverse = crate::physics::board_motion_output::inverse_length_squared(squared, 2);
let value = squared * inverse;
if squared == 0.0 { 0.0 } else { value }
}
fn subtract(a: Vector, b: Vector) -> Vector {
core::array::from_fn(|i| a[i] - b[i])
}
fn scaled_add(a: Vector, scale: f32, b: Vector) -> Vector {
core::array::from_fn(|i| a[i].mul_add(scale, b[i]))
}
fn candidate(
delta: Vector,
angle: f32,
upper_length: f32,
root: Vector,
basis: &Transform,
) -> Vector {
let (sine, cosine) = sin_cos(angle);
let rotation = [
[cosine, sine, 0.0, cosine],
[-sine, cosine, 0.0, -sine],
[0.0, 0.0, 1.0, 0.0],
[0.0; 4],
];
let rotated = normalize(transform_point(&rotation, delta));
transform_point(basis, scaled_add(rotated, upper_length, root))
}
@@ -0,0 +1,121 @@
//! Deck acceleration conditioning82DF0508 and IsBumped82BA7310, original TU3.
//! Matrices are the raw physical deck and Reckoning ground frames; the effective
//! animation deck is a different source. No render transform participates here.
use crate::physics::{native_arithmetic, skeleton_animation_record::AnimationPartTransform};
#[derive(Clone, Copy, Debug)]
pub struct Input {
/// PhysOutDeck0..48, actual Part6 transform (82C02AD8..2B20).
pub deck: AnimationPartTransform,
/// PhysOutGround0..48, Reckoning752..800 (82DB70E0..7100).
pub ground: AnimationPartTransform,
/// PhysOutMotion112, BoardBody528 from observed deck velocity differences.
pub world_acceleration: [f32; 4],
}
#[derive(Clone, Copy, Debug)]
pub struct Settings {
/// Globals344 anim_motion/bumps layout1700, scale_x_acc.
pub scale_x_acc: f32,
/// Same collection layout1768, min_bump_mag.
pub min_bump_mag: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct Output {
pub acceleration: [f32; 4],
pub bumped: bool,
}
pub fn publish(input: Input, settings: &Settings) -> Output {
let acceleration = condition(input);
Output {
acceleration,
bumped: is_bumped(acceleration, settings),
}
}
///82DF0508: remove deck-local Y, rotate back to world, express in the
///ground frame, then remove ground-local Y. Both original stores are required.
pub fn condition(input: Input) -> [f32; 4] {
let mut deck_local = inverse_rotate(input.deck, input.world_acceleration);
deck_local[1] = 0.0; //82DF05C4
let world = rotate(input.deck, deck_local);
let mut ground_local = inverse_rotate(input.ground, world);
ground_local[1] = 0.0; //82DF0600
ground_local
}
///82BA7310 scales X only, then compares the refined length strictly > threshold.
///Independent Rust estimates are used; this is not a bit-exact Xenon claim.
pub fn is_bumped(mut acceleration: [f32; 4], settings: &Settings) -> bool {
acceleration[0] *= settings.scale_x_acc;
let squared = native_arithmetic::dot3(acceleration, acceleration);
let mut reciprocal = native_arithmetic::reciprocal_square_root_estimate(squared);
for _ in 0..2 {
let square = reciprocal * reciprocal;
let half = reciprocal * 0.5;
let error = (-squared).mul_add(square, 1.0);
reciprocal = half.mul_add(error, reciprocal);
}
let magnitude = if squared == 0.0 {
0.0
} else {
squared * reciprocal
};
magnitude > settings.min_bump_mag
}
fn inverse_rotate(frame: AnimationPartTransform, vector: [f32; 4]) -> [f32; 4] {
core::array::from_fn(|lane| {
frame[lane][2].mul_add(
vector[2],
frame[lane][1].mul_add(vector[1], frame[lane][0] * vector[0]),
)
})
}
fn rotate(frame: AnimationPartTransform, vector: [f32; 4]) -> [f32; 4] {
core::array::from_fn(|lane| {
frame[2][lane].mul_add(
vector[2],
frame[1][lane].mul_add(vector[1], frame[0][lane] * vector[0]),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pitched_deck_is_projected_before_the_distinct_ground_frame() {
// Deck Y points world Z. Ground X points world Z. World Z is removed
// before changing frame; removing only final Y would incorrectly keep it.
let deck = [
[1., 0., 0., 0.],
[0., 0., 1., 0.],
[0., -1., 0., 0.],
[7., 8., 9., 0.],
];
let ground = [
[0., 0., 1., 0.],
[0., 1., 0., 0.],
[-1., 0., 0., 0.],
[0.; 4],
];
let result = condition(Input {
deck,
ground,
world_acceleration: [2., 3., 4., 0.],
});
assert_eq!(&result[..3], &[0., 0., -2.]);
}
#[test]
fn bump_uses_only_scaled_horizontal_magnitude_and_strict_threshold() {
let settings = Settings {
scale_x_acc: 2.,
min_bump_mag: 10.,
};
assert!(!is_bumped([0.; 4], &settings));
assert!(!is_bumped([0., 0., 10., 0.], &settings));
assert!(is_bumped([6., 0., 0., 0.], &settings));
assert!(!is_bumped([0., 0., 0., 999.], &settings));
}
}
@@ -0,0 +1,109 @@
//! FilterMotionGraphIntent TU3 82BB1730/17D0/19F0 and 82BB1B78.
//! Coefficients and limits are per graph update; only the ramp uses elapsed time.
use crate::input::graph_intents::apply_filter;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Settings {
pub starting_value: f32,
pub default_value: f32,
pub scale: f32,
pub filters: [u32; 4],
pub ramp_time: Option<f32>,
pub blend_rising: f32,
pub blend_falling: f32,
pub blend_out: Option<f32>,
pub clamp_velocity: Option<f32>,
pub clamp_acceleration: Option<f32>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct State {
pub elapsed: f32,
pub previous_delta: f32,
pub value: f32,
}
impl State {
/// Begin publishes startingValue directly, without scaling or filtering.
pub fn begin(&mut self, settings: &Settings) -> f32 {
self.elapsed = 0.0;
self.previous_delta = 0.0;
self.value = settings.starting_value;
self.value
}
/// Stance is the live animation component's fakie/mirrored getter pair.
pub fn update(
&mut self,
settings: &Settings,
input: Option<f32>,
dt: f32,
stance: (bool, bool),
) -> f32 {
self.elapsed += dt;
let mut target = settings.scale * input.unwrap_or(settings.default_value);
for (kind, enabled) in settings.filters.into_iter().zip([true, true, stance.0, stance.1]) {
if enabled && kind <= 7 {
target = apply_filter(target, kind);
}
}
let mut blend = if target < self.value {
settings.blend_falling
} else {
settings.blend_rising
};
if input.is_some() {
if let Some(time) = settings.ramp_time {
blend = ramp(self.elapsed, time) * blend;
}
} else if let Some(out) = settings.blend_out {
blend = out;
}
// 82BB194C..1968: separate target product, then fmadds, then subtract.
let weighted_target = blend * target;
let candidate = (1.0 - blend).mul_add(self.value, weighted_target);
let mut delta = candidate - self.value;
if let Some(limit) = settings.clamp_acceleration {
delta = limit_delta(delta, self.previous_delta - limit, self.previous_delta + limit);
}
if let Some(limit) = settings.clamp_velocity {
delta = limit_delta(delta, -limit, limit);
}
self.previous_delta = delta;
self.value = delta + self.value;
self.value
}
}
fn limit_delta(value: f32, lower: f32, upper: f32) -> f32 {
// Preserve the original subtract/select ordering, including unordered input.
let selected = if lower - value >= 0.0 { lower } else { value };
if upper - selected >= 0.0 { selected } else { upper }
}
fn ramp(elapsed: f32, time: f32) -> f32 {
// 82BA8720 uses signed integer comparisons of float bits in lower_bound.
// Constructor 82BB1570..15BC supplies precisely (0,0),(rampTime,1).
let keys = [0.0f32, time];
let mut begin = 0usize;
let mut count = 2usize;
while count != 0 {
let step = count / 2;
let middle = begin + step;
if (keys[middle].to_bits() as i32) < (elapsed.to_bits() as i32) {
begin = middle + 1;
count -= step + 1;
} else {
count = step;
}
}
match begin {
0 => 0.0,
2 => 1.0,
_ => ((1.0f32 - 0.0) / (time - 0.0)).mul_add(elapsed - 0.0, 0.0),
}
}
#[cfg(test)]
#[path = "intent_filter/tests.rs"]
mod tests;
@@ -0,0 +1,66 @@
use super::*;
fn settings() -> Settings {
Settings {
starting_value: 0.0, default_value: 0.0, scale: 1.0, filters: [0; 4],
ramp_time: None, blend_rising: 0.5, blend_falling: 0.25,
blend_out: Some(0.125), clamp_velocity: None, clamp_acceleration: None,
}
}
#[test]
fn starting_value_bypasses_scale_and_filters_and_reentry_resets_history() {
let mut s = State { elapsed: 4.0, previous_delta: 9.0, value: 7.0 };
let mut p = settings();
p.starting_value = 2.0;
p.scale = 3.0;
p.filters = [1; 4];
assert_eq!(s.begin(&p), 2.0);
assert_eq!(s.elapsed, 0.0);
assert_eq!(s.previous_delta, 0.0);
}
#[test]
fn missing_input_uses_blend_out_without_ramp_and_zero_is_present() {
let mut p = settings();
p.ramp_time = Some(8.0);
let mut s = State { value: 1.0, ..State::default() };
assert_eq!(s.update(&p, None, 1.0, (false, false)), 0.875);
s = State { value: 1.0, ..State::default() };
assert_eq!(s.update(&p, Some(0.0), 1.0, (false, false)), 0.96875);
}
#[test]
fn acceleration_precedes_velocity_and_limits_are_not_scaled_by_dt() {
let mut p = settings();
p.blend_rising = 1.0;
p.clamp_acceleration = Some(0.25);
p.clamp_velocity = Some(0.125);
let mut s = State::default();
assert_eq!(s.update(&p, Some(4.0), 0.0, (false, false)), 0.125);
assert_eq!(s.update(&p, Some(4.0), 3.0, (false, false)), 0.25);
assert_eq!(s.update(&p, Some(-4.0), 0.0, (false, false)), 0.125);
}
#[test]
fn scale_and_ordered_filters_use_live_stance() {
let mut p = settings();
p.blend_rising = 1.0;
p.blend_falling = 1.0;
p.scale = 2.0;
p.filters = [1, 2, 3, 1];
let mut s = State::default();
assert_eq!(s.update(&p, Some(0.125), 0.0, (false, false)), 0.25);
assert_eq!(s.update(&p, Some(0.125), 0.0, (true, true)), -0.75);
}
#[test]
fn ramp_has_native_endpoint_and_signed_bit_ordering() {
assert_eq!(ramp(0.0, 2.0), 0.0);
assert_eq!(ramp(0.5, 2.0), 0.25);
assert_eq!(ramp(2.0, 2.0), 1.0);
assert_eq!(ramp(3.0, 2.0), 1.0);
assert_eq!(ramp(-1.0, 2.0), 0.0);
assert_eq!(ramp(0.0, 0.0), 0.0);
assert_eq!(ramp(1.0, 0.0), 1.0);
}
+109
View File
@@ -0,0 +1,109 @@
//! Original Skate 3 TU3 KickTurnSteering: constructor82BAE730,
//! Begin82BAE430 and Update82BAE4A8. Curves are Globals324 anim_motion/kickturn.
use crate::point_graph::PointGraph;
#[derive(Clone, Debug)]
pub struct Settings {
pub spin: PointGraph<8>,
pub height: PointGraph<8>,
}
/// Authored behavior fields28/32; constructor82BC7218 defaults both to0.5.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Parameters {
pub max_height: f32,
pub spin_scale: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct State {
animation_length: f32,
elapsed: f32,
first_update: bool,
previous_height: f32,
last_nonzero_intent: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Output {
/// Published first, to the actual MotionGraph Balance attribute.
pub balance: f32,
pub spin: f32,
}
impl State {
pub fn new() -> Self {
Self {
animation_length: 0.0,
elapsed: 0.0,
first_update: true,
previous_height: 0.0,
last_nonzero_intent: 0.0,
}
}
pub fn begin(&mut self, settings: &Settings) {
*self = Self::new();
//82BAE480 seeds the unscaled curve value, before applying max_height.
self.previous_height = settings.height.evaluate(0.0);
}
pub fn needs_animation_length(&self) -> bool {
self.first_update
}
/// The caller first applies pending animation parameters, then reads the
/// base animation tree's length. No time is added on that first update.
pub fn capture_animation_length(&mut self, length: f32) {
self.animation_length = length;
}
pub fn update(
&mut self,
dt: f32,
intent: f32,
parameters: Parameters,
settings: &Settings,
) -> Output {
if !self.first_update {
self.elapsed += dt;
}
let phase = bound(self.elapsed / self.animation_length, 1.0);
let spin_curve = settings.spin.evaluate(phase);
let height_curve = settings.height.evaluate(phase);
//82BAE660 fmadds, followed by separate82BAE670 fmuls. TU3 fixes
//the blend coefficient at0.5; the S2 debug alternatives are absent.
let sum = height_curve.mul_add(parameters.max_height, self.previous_height);
let height = bound(sum * 0.5, parameters.max_height);
self.previous_height = height;
if intent != 0.0 {
self.last_nonzero_intent = intent;
}
let sign = if self.last_nonzero_intent >= 0.0 {
1.0
} else {
-1.0
};
let output = Output {
balance: -height,
spin: (parameters.spin_scale * sign) * spin_curve,
};
self.first_update = false;
output
}
}
//Source fsel(-value,0,value), then fsel(max-lower,lower,max).
//Preserves source unordered handling: a NaN selects the upper bound.
fn bound(value: f32, maximum: f32) -> f32 {
let lower = if -value >= 0.0 { 0.0 } else { value };
if maximum - lower >= 0.0 {
lower
} else {
maximum
}
}
#[cfg(test)]
#[path = "tests/kickturn.rs"]
mod tests;
@@ -0,0 +1,148 @@
//! Original TU3 PhysicsConditioner::CalcLandingQuality, 0x82DE61D0.
use crate::{
physics::{board_motion_output::inverse_length_squared, native_arithmetic},
point_graph::PointGraph,
};
#[derive(Clone, Copy, Debug)]
pub struct Settings {
pub twist_spin: PointGraph<4>,
pub side_speed: PointGraph<4>,
}
#[derive(Clone, Copy, Debug)]
pub struct Input {
/// PhysicsConditioner +144 and +148, after its filtered-state update.
pub previous_filtered_state: u32,
pub filtered_state: u32,
/// PhysOut_Collision +32. Do not substitute a predicted landing normal.
pub ground_normal: [f32; 4],
/// PhysOut_SkateboardMotion +80 and +273.
pub deck_velocity: [f32; 4],
pub flipped: bool,
/// PhysOut_SkateboardReckoning +32: physical deck PART world Z from
/// 82C02AD8/82C02B1C. This is not the conditioned Reckoning ground frame.
pub reckoning_forward: [f32; 4],
/// PhysOut_Air +204.
pub air_spin: f32,
}
/// Reset values are explicitly written by 0x82DE3F38.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Output {
pub landing_adjust_80: f32,
pub sideways_speed_84: f32,
pub forward_speed_88: f32,
pub spin_92: f32,
pub landing_type_96: u32,
pub landing_data_167: bool,
}
impl Output {
/// Nonlanding frames preserve every field, including the valid-data latch.
pub fn update(&mut self, input: Input, settings: &Settings) {
if input.previous_filtered_state == 1 || input.filtered_state != 1 {
return;
}
let velocity = planar(input.deck_velocity, input.ground_normal);
let forward = planar(input.reckoning_forward, input.ground_normal);
let speed = length(velocity);
let forward_length = length(forward);
let mut forward_speed = dot(velocity, forward).abs();
let mut side_speed = dot(cross(forward, input.ground_normal), input.deck_velocity).abs();
let mut kind = 0;
let mut spin = 0.0;
if forward_length * speed < f32::from_bits(0x3727_c5ac) {
forward_speed = 0.0;
side_speed = 0.0;
} else {
let normalized_velocity = scale(velocity, reciprocal(speed));
let normalized_forward = scale(forward, reciprocal(forward_length));
let mut orientation = cross(normalized_forward, normalized_velocity)[1];
if input.flipped {
orientation = -orientation;
}
let angular_speed = -input.air_spin;
let spin_input = (angular_speed.abs() - 0.1) * 0.14492753;
let spin_curve = settings.twist_spin.evaluate(upper_one(spin_input));
let side_input = (side_speed - 0.1) * 0.1010101;
let side_curve = settings.side_speed.evaluate(upper_one(side_input));
if speed >= 2.0 {
let heading = if input.flipped {
scale(normalized_forward, -1.0)
} else {
normalized_forward
};
let toward = dot(heading, normalized_velocity) >= 0.0;
let magnitude;
if angular_speed.abs() <= 0.1 {
kind = 1;
magnitude = side_curve;
} else {
let product = angular_speed * orientation;
kind = if (toward && product <= 0.0) || (!toward && product > 0.0) {
2
} else {
1
};
magnitude = if spin_curve - side_curve >= -0.0 {
spin_curve
} else {
side_curve
};
}
spin = if angular_speed >= -0.0 {
magnitude
} else {
-magnitude
};
if orientation.abs() < 0.2 && (kind != 2 || !(spin.abs() > 0.3)) {
kind = if orientation.abs() >= 0.05 { 3 } else { 0 };
spin = 0.0;
}
}
}
*self = Self {
landing_adjust_80: spin,
sideways_speed_84: side_speed,
forward_speed_88: forward_speed,
spin_92: spin,
landing_type_96: kind,
landing_data_167: true,
};
}
}
fn dot(a: [f32; 4], b: [f32; 4]) -> f32 {
native_arithmetic::dot3(a, b)
}
fn scale(a: [f32; 4], b: f32) -> [f32; 4] {
a.map(|x| x * b)
}
fn planar(a: [f32; 4], normal: [f32; 4]) -> [f32; 4] {
let projected = scale(normal, dot(normal, a));
std::array::from_fn(|i| a[i] - projected[i])
}
fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
[
(-a[2]).mul_add(b[1], a[1] * b[2]),
(-a[0]).mul_add(b[2], a[2] * b[0]),
(-a[1]).mul_add(b[0], a[0] * b[1]),
0.0,
]
}
fn length(a: [f32; 4]) -> f32 {
let square = dot(a, a);
let value = square * inverse_length_squared(square, 2);
if square == 0.0 { 0.0 } else { value }
}
fn reciprocal(value: f32) -> f32 {
let mut estimate = native_arithmetic::reciprocal_estimate(value);
for _ in 0..2 {
estimate = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate);
}
estimate
}
fn upper_one(value: f32) -> f32 {
if 1.0 - value >= -0.0 { value } else { 1.0 }
}
+33
View File
@@ -0,0 +1,33 @@
//! Animation timing and event semantics recovered from Skate 3 TU3.
pub mod clip_clock;
pub mod commands;
pub mod output;
pub mod skeleton_input;
pub mod playback;
pub mod playback_parameters;
pub mod playback_clip;
pub mod playback_attributes;
pub mod playback_tree;
pub mod phase_blend;
pub mod pose_blend;
pub mod playback_transition;
pub mod crouching;
pub mod pose_sample;
pub mod pose_trajectory;
pub mod pose_add;
pub mod pose_mirror;
pub mod body_tilt;
pub mod air_leg_extension;
pub mod landing_quality;
pub mod riding_fakie;
pub mod channel_playback;
pub mod pumping_channel;
pub mod physical_feedback;
pub mod ground_acceleration;
pub mod foot_ik;
pub mod vbr;
pub mod kickturn;
pub mod intent_filter;
pub mod posture;
@@ -0,0 +1,128 @@
//! Actor field publication825937EC..825939C8 and external payload copy82592810.
//! Runtime services remain explicit source interfaces; their implementation is
//! not supplied here and these fields do not manufacture a gameplay impulse.
use super::{packet_reset::AdditionalResetFields, physics_packet::PhysicsPosePacket};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExternalPhysicsInput {
/// Ten complete vectors; word representation preserves non-arithmetic bits.
pub vectors: [[u32; 4]; 10],
pub flags: u32,
}
impl ExternalPhysicsInput {
///82592810 copies160 bytes and only the high seven flag bits. Remaining
/// destination flag bits and native padding are not overwritten.
pub fn copy_from(&mut self, source: &Self) {
self.vectors = source.vectors;
self.flags = (self.flags & 0x01ff_ffff) | (source.flags & 0xfe00_0000);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ExternalReset {
pub transform: [[u32; 4]; 4],
/// Source payload+64 -> packet10768; retains the complete byte.
pub byte64: u8,
}
pub struct ActorPacketFields {
pub external_impulse: [u32; 4],
pub external_physics: ExternalPhysicsInput,
pub external_reset: ExternalReset,
/// Actor1904 bit29 -> packet10784; not reset by82590028.
pub actor_flag_1904_bit29: bool,
}
pub struct ActorPublicationState {
pub flags1904: u32,
pub flags1908: u32,
pub external_controller_present: bool,
pub external_impulse: [u32; 4],
pub external_physics: ExternalPhysicsInput,
pub external_reset: ExternalReset,
/// Actor1768 selects the native168-byte per-player settings record.
pub player_index: u32,
}
/// A source-owned binding to the settings object captured before Actor+56's
/// virtual call. Each read uses the same captured object and168-byte stride.
pub trait PlayerPhysicsSettingsSource {
type Error;
/// Record+160. This read is skipped by the fixed-settings branch.
fn truck_tightness(&mut self, player_index: u32) -> Result<f32, Self::Error>;
/// Record+164. This read is skipped by the fixed-settings branch.
fn wheel_hardness(&mut self, player_index: u32) -> Result<f32, Self::Error>;
/// Record+172. Read after either branch has written both output scalars.
fn requested_physics_mode(&mut self, player_index: u32) -> Result<u32, Self::Error>;
}
/// Exact unresolved external service boundaries for this field publisher.
/// A game adapter must implement these from their source-owned systems.
pub trait ActorPublicationEnvironment {
/// Implementations must report unavailable source services as an error.
/// There is deliberately no default or placeholder implementation.
type Error;
type Settings: PlayerPhysicsSettingsSource<Error = Self::Error>;
///8259FC28: global830CFD94 ->220 ->328 virtual12. None means no current
/// object. Otherwise property824639E8(object20), including its native
/// default, supplies the enum. Property identifier is-616069660.
fn current_scene_mode(&mut self) -> Result<Option<i32>, Self::Error>;
/// Read global byte830B7C30 verbatim, without bool normalization.
fn ignore_respawn_reset_button(&mut self) -> Result<u8, Self::Error>;
/// Actor subobject+56 virtual16, low return byte. Flags25/28 have already
/// been consumed when called. Concrete vtable binding remains unresolved.
fn actor_subobject56_slot16(
&mut self,
actor: &mut ActorPublicationState,
) -> Result<u8, Self::Error>;
/// Capture global83067068 at82593954 before slot16 at8259395C. The binding
/// must stay valid across that callback and the subsequent field reads.
/// Do not snapshot record values here: native reads them after the callback.
fn capture_physics_settings(&mut self) -> Result<Self::Settings, Self::Error>;
}
/// Full actor-owned suffix on distinct actor/packet payload storage. Scene
/// lookup is skipped for an external controller or force-braking bit26.
/// This stops before the physical object's borrowed-packet setter82DB3C48.
pub fn publish<E: ActorPublicationEnvironment>(
actor: &mut ActorPublicationState,
pose: &mut PhysicsPosePacket,
reset: &mut AdditionalResetFields,
fields: &mut ActorPacketFields,
environment: &mut E,
) -> Result<(), E::Error> {
reset.actor_flag_1904_bit23 = actor.flags1904 & (1 << 23) != 0;
reset.actor_flag_1908_bit2 = actor.flags1908 & (1 << 2) != 0;
reset.external_impulse_active = actor.flags1904 & (1 << 30) != 0;
fields.external_impulse = actor.external_impulse;
fields.external_physics.copy_from(&actor.external_physics);
reset.external_physics_input_active = actor.flags1904 & (1 << 27) != 0;
let scene_mode_flag = !actor.external_controller_present
&& actor.flags1904 & (1 << 26) == 0
&& matches!(environment.current_scene_mode()?, Some(19 | 20));
replace_flag(&mut pose.flags, 22, scene_mode_flag);
reset.externally_controlled = actor.external_controller_present;
fields.external_reset = actor.external_reset;
fields.actor_flag_1904_bit29 = actor.flags1904 & (1 << 29) != 0;
reset.prevent_manual_respawn = actor.flags1904 & (1 << 28) != 0;
reset.force_braking = actor.flags1904 & (1 << 26) != 0;
replace_flag(&mut pose.flags, 24, actor.flags1904 & (1 << 25) != 0);
actor.flags1904 &= !(1 << 25);
reset.ignore_respawn_reset_button = environment.ignore_respawn_reset_button()?;
actor.flags1904 &= !(1 << 28);
let mut settings = environment.capture_physics_settings()?;
let use_fixed_settings = environment.actor_subobject56_slot16(actor)? != 0;
if use_fixed_settings {
reset.truck_tightness = f32::from_bits(0x3f33_3333);
reset.wheel_hardness = f32::from_bits(0x3f33_3333);
} else {
reset.truck_tightness = settings.truck_tightness(actor.player_index)?;
reset.wheel_hardness = settings.wheel_hardness(actor.player_index)?;
}
reset.requested_physics_mode = settings.requested_physics_mode(actor.player_index)?;
Ok(())
}
fn replace_flag(flags: &mut u32, bit: u32, value: bool) {
*flags = (*flags & !(1 << bit)) | (u32::from(value) << bit);
}
@@ -0,0 +1,120 @@
//! Attribute copying and ordered packet merge from TU3 8258EED0/8258F210.
//! Binary names stay at this typed boundary. This module does not interpret
//! Skeleton attributes or invent a physics effect from their names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AttributeName(pub [u32; 5]);
/// The six union lanes retain initialization separately from value. Copying a
/// scalar must not fabricate values for the other five lanes. Words preserve
/// all floating-point payload bits across non-arithmetic publication.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AttributePayload(pub [Option<u32>; 6]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnimationAttribute {
pub payload: AttributePayload,
pub begin_time: f32,
pub end_time: f32,
pub name: AttributeName,
pub status: u8,
/// Native+53:0/2 scalar,1 vector,3 six-word payload; other kinds copy none.
pub kind: u8,
pub sequence_id: i32,
}
impl AnimationAttribute {
/// Complete observable field assignment8258EED0. Native padding at54/55
/// and60..63 is not copied and has no represented domain field.
pub fn copy_from(&mut self, source: &Self) {
self.kind = source.kind;
self.name = source.name;
self.begin_time = source.begin_time;
self.end_time = source.end_time;
self.status = source.status;
self.sequence_id = source.sequence_id;
let lanes = match self.kind {
0 | 2 => 1,
1 => 4,
3 => 6,
_ => 0,
};
self.payload.0[..lanes].copy_from_slice(&source.payload.0[..lanes]);
}
/// Copy-construction8258EE68 initializes name storage then performs the
/// assignment above. Its inactive union lanes are unspecified, not zero.
fn copy_construct(source: &Self) -> Self {
let mut result = Self {
payload: AttributePayload([None; 6]),
..*source
};
result.copy_from(source);
result
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MotionGraphAttribute {
/// Native24-byte MG record:20-byte name followed by scalar.
pub name: AttributeName,
pub value: f32,
}
impl MotionGraphAttribute {
///8258F210 makes an untimed scalar attribute with status6 and sequence-1.
pub fn to_animation(self) -> AnimationAttribute {
let mut payload = AttributePayload([None; 6]);
payload.0[0] = Some(self.value.to_bits());
AnimationAttribute {
payload,
begin_time: -1.0,
end_time: -1.0,
name: self.name,
status: 6,
kind: 0,
sequence_id: -1,
}
}
}
/// Active entries plus retained slots model native end=begin list reset.
/// Capacity allocation policy is host-owned; active ordering and union-lane
/// preservation follow native append/copy semantics. No sorting/deduplication.
#[derive(Default)]
pub struct PacketAttributes {
slots: Vec<AnimationAttribute>,
active_len: usize,
}
impl PacketAttributes {
pub fn entries(&self) -> &[AnimationAttribute] {
&self.slots[..self.active_len]
}
pub fn clear(&mut self) {
self.active_len = 0;
}
pub fn append(&mut self, source: &AnimationAttribute) {
if let Some(slot) = self.slots.get_mut(self.active_len) {
slot.copy_from(source);
} else {
self.slots.push(AnimationAttribute::copy_construct(source));
}
self.active_len += 1;
}
/// Exact list operation order at82593750..825937E4: discard prior active
/// list, append converted MG records, then tree records in stored order.
/// Duplicate names remain distinct records for the later Skeleton dispatch.
pub fn replace_from(
&mut self,
motion_graph: &[MotionGraphAttribute],
tree: &[AnimationAttribute],
) {
self.clear();
for attribute in motion_graph {
self.append(&attribute.to_animation());
}
for attribute in tree {
self.append(attribute);
}
}
}
@@ -0,0 +1,101 @@
use super::{NativeMatrix, PoseBufferError};
/// TU3 0x828D3B58: local matrices to hierarchy-composed matrices.
///
/// `detached_parent` is native r5 (hierarchy +8 at the caller). A parent equal to
/// it, or -1, causes a verbatim local copy. No world transform is introduced.
/// The first matrix is copied even for a nonpositive count. Other output slots
/// retain caller state until visited, including when a forward parent is read.
pub fn compose_hierarchy(
bone_count: i32,
parents: &[i32],
detached_parent: i32,
local: &[NativeMatrix],
destination: &mut [NativeMatrix],
) -> Result<(), PoseBufferError> {
let count = bone_count.max(0) as usize;
validate(
count,
parents,
detached_parent,
local.len(),
destination.len(),
)?;
destination[0] = local[0];
for bone in 0..count {
let parent = parents[bone];
destination[bone] = if parent == -1 || parent == detached_parent {
local[bone]
} else {
concatenate(local[bone], destination[parent as usize])
};
}
Ok(())
}
/// The same native function with identical source/destination matrix pointers.
pub fn compose_hierarchy_in_place(
bone_count: i32,
parents: &[i32],
detached_parent: i32,
matrices: &mut [NativeMatrix],
) -> Result<(), PoseBufferError> {
let count = bone_count.max(0) as usize;
validate(
count,
parents,
detached_parent,
matrices.len(),
matrices.len(),
)?;
for bone in 0..count {
let parent = parents[bone];
if parent != -1 && parent != detached_parent {
matrices[bone] = concatenate(matrices[bone], matrices[parent as usize]);
}
}
Ok(())
}
fn validate(
count: usize,
parents: &[i32],
detached_parent: i32,
input_len: usize,
output_len: usize,
) -> Result<(), PoseBufferError> {
if input_len < count.max(1) {
return Err(PoseBufferError::ShortInput);
}
if output_len < count.max(1) {
return Err(PoseBufferError::ShortOutput);
}
if parents.len() < count {
return Err(PoseBufferError::ShortParents);
}
for (bone, &parent) in parents[..count].iter().enumerate() {
if parent != -1
&& parent != detached_parent
&& (parent < 0 || parent as usize >= output_len)
{
return Err(PoseBufferError::InvalidParent { bone, parent });
}
}
Ok(())
}
fn concatenate(local: NativeMatrix, parent: NativeMatrix) -> NativeMatrix {
let mut result = local;
for row in 0..4 {
for lane in 0..4 {
let first = if row == 3 {
parent[0][lane].mul_add(local[row][0], parent[3][lane])
} else {
parent[0][lane] * local[row][0]
};
let second = parent[1][lane].mul_add(local[row][1], first);
result[row][lane] = parent[2][lane].mul_add(local[row][2], second);
}
}
result
}
@@ -0,0 +1,155 @@
//! Fixed intent-map specialization constructed by8258F638, used at full
//! MotionGraph+12 (82C0E088) and physics packet+8 (8258FEE8).
//! Names are six encoded words; no guessed text hashing or guest addresses.
use crate::input::graph_intents::IntentMutation;
pub const BUCKET_COUNT: usize = 61;
pub const ENTRY_CAPACITY: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IntentName(pub [u32; 6]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Intent {
pub name: IntentName,
pub value: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IntentCapacityExceeded;
/// Native construction takes greatest prime<=65 (61), installs64 nodes, then
/// changes maximum load to10000. Thus every valid state of this specialization
/// stays at61 buckets; generic rehash policy is unreachable before exhaustion.
/// Owned bucket vectors represent chains in traversal order. Allocator node
/// identities/free-list addresses are deliberately outside the domain model.
pub struct IntentMap {
buckets: [Vec<Intent>; BUCKET_COUNT],
len: usize,
}
impl Default for IntentMap {
fn default() -> Self {
Self {
buckets: std::array::from_fn(|_| Vec::new()),
len: 0,
}
}
}
impl IntentMap {
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn get(&self, name: IntentName) -> Option<f32> {
let hash = name.0.iter().fold(0u32, |sum, word| sum.wrapping_add(*word));
self.buckets[hash as usize % BUCKET_COUNT]
.iter()
.find(|entry| entry.name == name)
.map(|entry| entry.value)
}
///826C20B0/825A0350: ascending buckets, head-to-tail within each chain.
pub fn entries(&self) -> impl Iterator<Item = &Intent> {
self.buckets.iter().flat_map(|bucket| bucket.iter())
}
///82472CD0/82472E10/824714D0: wrapping sum of all six words, exact
/// six-word equality, prepend new entries. Existing values are preserved.
/// At exhausted capacity native faults; the host rejects the new entry.
pub fn insert(&mut self, intent: Intent) -> Result<bool, IntentCapacityExceeded> {
let hash = intent
.name
.0
.iter()
.fold(0u32, |sum, word| sum.wrapping_add(*word));
let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT];
if bucket.iter().any(|entry| entry.name == intent.name) {
return Ok(false);
}
if self.len == ENTRY_CAPACITY {
return Err(IntentCapacityExceeded);
}
bucket.insert(0, intent);
self.len += 1;
Ok(true)
}
/// Set the value at an existing native key, or insert a new key. The
/// native 82471818 lookup returns a value slot before the caller writes
/// it, so Set must overwrite an existing entry rather than silently
/// preserving it as `insert` does.
pub fn set(&mut self, name: IntentName, value: f32) -> Result<bool, IntentCapacityExceeded> {
let hash = name
.0
.iter()
.fold(0u32, |sum, word| sum.wrapping_add(*word));
let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT];
if let Some(entry) = bucket.iter_mut().find(|entry| entry.name == name) {
entry.value = value;
return Ok(false);
}
self.insert(Intent { name, value })
}
/// Remove one exact six-word key using the same bucket and chain order as
/// native 82BC1068. Removing an entry leaves the relative order of the
/// remaining chain untouched.
pub fn remove(&mut self, name: IntentName) -> bool {
let hash = name
.0
.iter()
.fold(0u32, |sum, word| sum.wrapping_add(*word));
let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT];
let Some(index) = bucket.iter().position(|entry| entry.name == name) else {
return false;
};
bucket.remove(index);
self.len -= 1;
true
}
/// Apply the graph host's existing mutation protocol at one decoded key.
/// `IntentName` is deliberately supplied by the graph decoder: this
/// module does not invent a string-to-six-word encoding.
pub fn apply(
&mut self,
name: IntentName,
mutation: IntentMutation,
) -> Result<bool, IntentCapacityExceeded> {
match mutation {
IntentMutation::None => Ok(false),
IntentMutation::Remove => Ok(self.remove(name)),
IntentMutation::Set(value) => self.set(name, value),
}
}
///82BC1B68 clears all chains but preserves bucket configuration.
pub fn clear(&mut self) {
for bucket in &mut self.buckets {
bucket.clear();
}
self.len = 0;
}
///825936FC..82593740: clear then range-insert, not a structural clone.
/// Each colliding source chain therefore reverses in the destination.
/// Canonical graph and packet are distinct owned maps. A caller binding a
/// map to itself must retain it instead, matching the native identity guard.
pub fn replace_from(&mut self, source: &Self) {
self.clear();
for &entry in source.entries() {
// Both maps enforce the same64-entry bound and exact key uniqueness.
self.insert(entry).expect("bounded intent map assignment");
}
}
}
#[cfg(test)]
#[path = "tests/intents.rs"]
mod tests;
@@ -0,0 +1,27 @@
//! Native TU3 pose-output kernels. These do not evaluate clips or choose graphs.
pub mod actor_packet;
pub mod attributes;
mod hierarchy;
pub mod intents;
pub mod motion_graph_packet;
pub mod packet_reset;
pub mod physics_packet;
pub mod setup_physics;
mod sqt;
pub use hierarchy::{compose_hierarchy, compose_hierarchy_in_place};
pub use sqt::{Sqt, sqt_to_local, sqt_to_matrix};
/// Four consecutive native vectors, including all packed fourth lanes.
/// Row/column conversion for a renderer belongs outside this module.
pub type NativeMatrix = [[f32; 4]; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PoseBufferError {
CountOverflow,
ShortInput,
ShortOutput,
ShortParents,
InvalidCacheOffset,
InvalidParent { bone: usize, parent: i32 },
}
@@ -0,0 +1,45 @@
//! Concrete SkaterMotionGraph::GetPhysUpdateData82595FA8 and copy82596008.
//! Actor construction82590DC0 ->8258F488 installs base vtable823006D0;
//! slot36 resolves82595FA8. Unlike the reviewed Skate2 counterpart, this
//! Skate3 body does not reset trick, gesture or wipeout source state afterward.
/// Native name storage at the data boundary; no addresses or host pointers.
/// Exact text/hash member interpretation is a separate string-format contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TrickIdentifier(pub [u32; 6]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrickData {
pub identifiers: [TrickIdentifier; 2],
/// Native trick record+48. Its producer/enum meaning is not recovered here.
pub word48: u32,
/// Native record+52/+56; copied by lfs/stfs.
pub scalars52_56: [f32; 2],
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GestureData {
pub gesture: u32,
/// The native body copies the complete second word, including unused bytes.
pub flag_bytes: [u8; 4],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MotionGraphPacket {
/// Source MG+5924 -> physics packet+10392,60 bytes.
pub trick: TrickData,
/// MG+5984/+5988 -> packet+10452/+10456.
pub gesture: GestureData,
/// MG+5992/+5996 -> packet+10460/+10464; complete words are copied.
pub wipeout_gesture: [f32; 2],
}
/// Complete concrete publication. Timestep is unused by82595FA8; no source
/// request is consumed, and repeated publication copies the same state.
pub fn publish(source: &MotionGraphPacket, destination: &mut MotionGraphPacket) {
destination.trick = source.trick;
destination.gesture.gesture = source.gesture.gesture;
destination.gesture.flag_bytes = source.gesture.flag_bytes;
destination.wipeout_gesture[0] = source.wipeout_gesture[0];
destination.wipeout_gesture[1] = source.wipeout_gesture[1];
}
@@ -0,0 +1,95 @@
//! AnimOutPhysIn::Reset, TU3 82590028.
//! Reset is selective: it preserves storage, count, intents, attributes and all
//! fields not listed below. Actor::SetUpPhysics owns the later list replacement.
use super::{NativeMatrix, physics_packet::PhysicsPosePacket};
use crate::animation::commands::buffers::BufferError;
/// Additional fields written by Reset, kept separate from the existing pose
/// publication view. Unknown flags retain their verified producer/offset names.
pub struct AdditionalResetFields {
/// 10388; 10912/10916.
pub compression: f32,
pub foot_ik_influence: [f32; 2],
/// 10369. Position itself is preserved by Reset.
pub next_step_position_valid: bool,
/// 10375/10376; overwritten from these Actor bits during SetUpPhysics.
pub actor_flag_1904_bit23: bool,
pub actor_flag_1908_bit2: bool,
/// 10496,10688,10689: active markers only; payloads are not cleared here.
pub external_impulse_active: bool,
pub external_physics_input_active: bool,
pub externally_controlled: bool,
/// 10786/10787/10796.
pub prevent_manual_respawn: bool,
pub ignore_respawn_reset_button: u8,
pub force_braking: bool,
/// 10788/10792. Named source fields, same target scalar reset/caller roles.
pub truck_tightness: f32,
pub wheel_hardness: f32,
/// Six complete native vectors at10816,10832,10848,10864,10880,10896.
/// Individual target semantics remain unassigned; all fourth lanes reset.
pub auxiliary_vectors: [[f32; 4]; 6],
/// 10928, later overwritten by the Actor's selected physics settings.
pub requested_physics_mode: u32,
}
/// Native packed basis, NOT a conventional homogeneous 4x4 identity.
/// Three static vectors at82139A10/20/30 plus an all-zero vector produced
/// inside Reset. All four fourth lanes, including translation W, are zero.
pub const RESET_POSE: NativeMatrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0; 4],
];
/// Complete field coverage for82590028 on valid packet allocations. Host-side
/// extent checks precede mutation; native assumes allocations match the count.
/// Existing vector lengths/capacities and bones past packet.bone_count remain.
pub fn reset(
packet: &mut PhysicsPosePacket,
fields: &mut AdditionalResetFields,
) -> Result<(), BufferError> {
// Both native loop comparisons are signed. A high-bit count skips the
// matrix loop while scalar/flag resets still run.
let count = (packet.bone_count as i32).max(0) as usize;
if packet.hierarchy.len() < count || packet.local.len() < count {
return Err(BufferError::RangeOutsideAllocation);
}
packet.timestep = f32::from_bits(0x3c88_8889);
fields.external_impulse_active = false;
fields.compression = 0.5;
fields.foot_ik_influence[0] = 0.0;
fields.external_physics_input_active = false;
fields.foot_ik_influence[1] = 0.0;
fields.externally_controlled = false;
fields.truck_tightness = 0.0;
packet.mirrored = false;
packet.riding_switch = false;
packet.riding_fakie = false;
packet.weight_forwards = false;
packet.regular_stance = false;
fields.prevent_manual_respawn = false;
fields.ignore_respawn_reset_button = 0;
fields.force_braking = false;
packet.air_dismount_revert_frames = 0;
packet.board_flipped = false;
fields.next_step_position_valid = false;
fields.actor_flag_1904_bit23 = false;
packet.flags &= 0x013f_ffff;
fields.actor_flag_1908_bit2 = false;
packet.foot_surface_ids[0] = 0;
fields.wheel_hardness = 0.0;
packet.foot_surface_ids[1] = 0;
fields.auxiliary_vectors[0] = [0.0; 4];
fields.requested_physics_mode = 1;
for vector in &mut fields.auxiliary_vectors[1..] {
*vector = [0.0; 4];
}
for bone in 0..count {
// Native completes local first, then hierarchy, for each bone.
packet.local[bone] = RESET_POSE;
packet.hierarchy[bone] = RESET_POSE;
}
Ok(())
}
@@ -0,0 +1,149 @@
//! Concrete SkaterAnim::GetPhysUpdateData, TU3 82B985E8.
//! This owns only fields written by that function, not the full AnimOutPhysIn
//! reset, motion-graph merge, attribute dispatch, or physical force consumers.
use super::NativeMatrix;
use crate::animation::commands::{
batch::CompletedSetData,
buffers::{BoneSlice, BufferError},
};
/// Optional object at SkaterAnim+15104. The original class name is unresolved.
pub struct AnimationSignal {
pub name_hash: u32,
pub active: u8,
}
/// Fields read or consumed from the concrete native SkaterAnim. Other object
/// fields remain with their owners. The two unknown requests are NOT push flags.
pub struct SkaterPublicationState {
/// Native packed flags+15180 bits31,30,29,28 respectively.
pub orientation_bit31: bool,
pub mirrored: bool,
pub riding_fakie: bool,
pub weight_on_nose: bool,
/// +15196 and +15188 are distinct enums. Only equality is tested here.
pub relative_stance: i32,
pub natural_stance: i32,
/// Native flags bits16 and15, consumed at publication.
pub request_bit16: bool,
pub request_bit15: bool,
/// Native flags bit20 and persistent count+15184.
pub air_dismount_revert_requested: bool,
pub air_dismount_revert_frames: i32,
pub signal: Option<AnimationSignal>,
}
pub struct ActorPoseBuffers {
/// Actor-owned hierarchy-global matrices, full SkaterAnim+13460.
pub hierarchy: BoneSlice<NativeMatrix>,
/// Actor-owned local matrices, full SkaterAnim+13468.
pub local: BoneSlice<NativeMatrix>,
}
/// Physics-packet-owned arrays are separate from the animation allocations.
/// Count is packet+10380, which native publication reads without rewriting.
/// Unlisted packet fields must be preserved by the surrounding packet owner.
pub struct PhysicsPosePacket {
pub bone_count: u32,
pub hierarchy: Vec<NativeMatrix>,
pub local: Vec<NativeMatrix>,
pub timestep: f32,
pub foot_surface_ids: [u32; 2],
pub flags: u32,
pub board_flipped: bool,
pub mirrored: bool,
pub riding_switch: bool,
pub riding_fakie: bool,
pub weight_forwards: bool,
pub regular_stance: bool,
pub air_dismount_revert_frames: i32,
}
/// Publish after the source worker completes. Before modifying state, this safe
/// adapter rejects invalid/uninitialized ranges; native assumes valid storage.
/// `signal_name` is the current string selected by native global830301E4 (the
/// inspected TU3 image selects "signup"). Supply it from verified startup data.
/// Concrete getters 82B970D8/82B97100/82B97128 are pure field reads here;
/// polymorphic replacement getters are outside this concrete SkaterAnim port.
pub fn publish(
state: &mut SkaterPublicationState,
completed: &CompletedSetData,
poses: &ActorPoseBuffers,
packet: &mut PhysicsPosePacket,
timestep: f64,
signal_name: &[u8],
) -> Result<i32, BufferError> {
let count = packet.bone_count as usize;
if packet.hierarchy.len() < count || packet.local.len() < count {
return Err(BufferError::RangeOutsideAllocation);
}
let hierarchy = completed.buffers().matrices.read(poses.hierarchy, count)?;
let local = completed.buffers().matrices.read(poses.local, count)?;
publish_evaluated(state, &hierarchy, &local, packet, timestep, signal_name)
}
/// Same publication with host-owned evaluated matrices. Completing animation
/// evaluation before this call preserves the source dependency without
/// requiring the original job/cache allocation system in the game.
pub fn publish_evaluated(
state: &mut SkaterPublicationState,
hierarchy: &[NativeMatrix],
local: &[NativeMatrix],
packet: &mut PhysicsPosePacket,
timestep: f64,
signal_name: &[u8],
) -> Result<i32, BufferError> {
let count=packet.bone_count as usize;
if hierarchy.len()<count || local.len()<count || packet.hierarchy.len()<count || packet.local.len()<count {
return Err(BufferError::RangeOutsideAllocation);
}
packet.timestep = timestep as f32;
packet.foot_surface_ids = [0; 2];
replace_flag(&mut packet.flags, 27, state.request_bit16);
state.request_bit16 = false;
replace_flag(&mut packet.flags, 23, state.request_bit15);
state.request_bit15 = false;
packet.flags &= !((1 << 25) | (1 << 26));
if let Some(signal) = &mut state.signal {
signal.name_hash = signal_hash(signal_name);
signal.active = 0;
}
packet.hierarchy[..count].copy_from_slice(&hierarchy[..count]);
packet.local[..count].copy_from_slice(&local[..count]);
packet.board_flipped = state.orientation_bit31 ^ state.mirrored;
packet.mirrored = state.mirrored;
packet.riding_switch = state.relative_stance == 1;
packet.riding_fakie = state.riding_fakie;
packet.weight_forwards = if state.riding_fakie {
!state.weight_on_nose
} else {
state.weight_on_nose
};
packet.regular_stance = state.natural_stance == 0;
replace_flag(&mut packet.flags, 28, state.air_dismount_revert_requested);
state.air_dismount_revert_requested = false;
packet.air_dismount_revert_frames = state.air_dismount_revert_frames;
Ok(state.air_dismount_revert_frames)
}
fn replace_flag(flags: &mut u32, bit: u32, value: bool) {
*flags = (*flags & !(1 << bit)) | (u32::from(value) << bit);
}
/// 8296EA10: NUL-terminated, signed-byte name hashing. This deliberately does
/// not substitute a library hash or unsigned-byte variant.
pub fn signal_hash(name: &[u8]) -> u32 {
let mut hash = 0_u32;
for &byte in name.iter().take_while(|&&byte| byte != 0) {
hash = hash
.wrapping_shl(4)
.wrapping_add((byte as i8 as i32) as u32);
let high = hash & 0xf000_0000;
if high != 0 {
hash ^= (high >> 23) ^ high;
}
}
hash
}
@@ -0,0 +1,100 @@
//! Source-bound packet composition for concrete Actor::SetUpPhysics82593640.
//! The caller supplies the runtime services and completed materialized poses.
//! This is not the complete Actor update or Skeleton attribute interpreter.
use super::{
actor_packet::{self, ActorPacketFields, ActorPublicationEnvironment, ActorPublicationState},
attributes::{AnimationAttribute, MotionGraphAttribute, PacketAttributes},
intents::IntentMap,
motion_graph_packet::{self, MotionGraphPacket},
packet_reset::{self, AdditionalResetFields},
physics_packet::{self, ActorPoseBuffers, PhysicsPosePacket, SkaterPublicationState},
};
use crate::animation::commands::{batch::CompletedSetData, buffers::BufferError};
/// Views cover fields touched by this concrete publication chain. Packet fields
/// only used by later Skeleton consumers are not fabricated here.
pub struct PhysicsInputPacket {
pub pose: PhysicsPosePacket,
pub reset: AdditionalResetFields,
pub motion_graph: MotionGraphPacket,
pub intents: IntentMap,
pub attributes: PacketAttributes,
pub actor: ActorPacketFields,
}
pub struct AnimationPublication<'a> {
pub state: &'a mut SkaterPublicationState,
pub completed: &'a CompletedSetData,
pub poses: &'a ActorPoseBuffers,
pub signal_name: &'a [u8],
pub tree_attributes: &'a [AnimationAttribute],
}
pub enum IntentPublication<'a> {
MotionGraph(&'a IntentMap),
/// Exact same-container branch825936FC; no clear/reinsert occurs.
RetainPacket,
}
pub struct MotionGraphPublication<'a> {
pub state: &'a MotionGraphPacket,
pub intents: IntentPublication<'a>,
pub attributes: &'a [MotionGraphAttribute],
}
#[derive(Debug, PartialEq, Eq)]
pub enum PublicationError<E> {
Pose(BufferError),
RuntimeService(E),
}
///82DB3C48 only stores a borrowed packet pointer at physical+1808. This safe
/// binding keeps the entire owned packet alive and prevents its next mutation
/// while a consumer reads it. It does not execute the downstream physics step.
pub struct PhysicsPacketBinding<'a> {
packet: &'a PhysicsInputPacket,
}
impl<'a> PhysicsPacketBinding<'a> {
pub fn packet(&self) -> &'a PhysicsInputPacket {
self.packet
}
}
/// Concrete canonical virtual144 is a no-op. Remaining phases execute in the
/// native order: reset, SkaterAnim, MG, intents, MG/tree attrs, actor suffix,
/// then packet binding. Failure preserves writes already made and returns no
/// binding; the caller must not consume the partially published packet.
pub fn compose<'a, E: ActorPublicationEnvironment>(
packet: &'a mut PhysicsInputPacket,
animation: AnimationPublication<'_>,
graph: MotionGraphPublication<'_>,
actor: &mut ActorPublicationState,
environment: &mut E,
) -> Result<PhysicsPacketBinding<'a>, PublicationError<E::Error>> {
packet_reset::reset(&mut packet.pose, &mut packet.reset).map_err(PublicationError::Pose)?;
physics_packet::publish(
animation.state,
animation.completed,
animation.poses,
&mut packet.pose,
f64::from(f32::from_bits(0x3c88_8889)),
animation.signal_name,
)
.map_err(PublicationError::Pose)?;
motion_graph_packet::publish(graph.state, &mut packet.motion_graph);
if let IntentPublication::MotionGraph(source) = graph.intents {
packet.intents.replace_from(source);
}
packet
.attributes
.replace_from(graph.attributes, animation.tree_attributes);
actor_packet::publish(
actor,
&mut packet.pose,
&mut packet.reset,
&mut packet.actor,
environment,
)
.map_err(PublicationError::RuntimeService)?;
Ok(PhysicsPacketBinding { packet })
}
@@ -0,0 +1,103 @@
use super::{NativeMatrix, PoseBufferError};
/// TU3 48-byte SQT record: scale, quaternion (x,y,z,w), translation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sqt {
pub scale: [f32; 4],
pub rotation: [f32; 4],
pub translation: [f32; 4],
}
/// Complete arithmetic/store behavior of TU3 0x828D3800.
///
/// The native SIMD loop writes four records at a time, including padding beyond
/// `bone_count`. The native `dcbzl` additionally zeros the following cache line;
/// `cache_offset` specifies the destination address modulo 128 (a multiple of 16).
/// Destination must include that trailing storage. No normalization or
/// bind-pose composition occurs. Unlike native speculative loads, this safe
/// adapter does not read a further unused group beyond the padded inputs.
pub fn sqt_to_local(
bone_count: usize,
cache_offset: u8,
source: &[Sqt],
destination: &mut [NativeMatrix],
) -> Result<usize, PoseBufferError> {
if cache_offset >= 128 || cache_offset % 16 != 0 {
return Err(PoseBufferError::InvalidCacheOffset);
}
let padded = bone_count
.checked_add(3)
.ok_or(PoseBufferError::CountOverflow)?
& !3;
if source.len() < padded {
return Err(PoseBufferError::ShortInput);
}
let trailing_words = if padded == 0 {
0
} else {
(128 - usize::from(cache_offset)) / 4
};
let required = padded
.checked_add(trailing_words.div_ceil(16))
.ok_or(PoseBufferError::CountOverflow)?;
if destination.len() < required {
return Err(PoseBufferError::ShortOutput);
}
// Earlier zeroed lines are overwritten by later groups; only the final tail
// remains visible. No typed input/output aliasing is allowed by this adapter.
for word in destination[padded..required]
.iter_mut()
.flatten()
.flatten()
.take(trailing_words)
{
*word = 0.0;
}
for (input, output) in source[..padded].iter().zip(&mut destination[..padded]) {
*output = sqt_to_matrix(*input);
}
Ok(padded)
}
/// Per-bone numerical result828D3800. The game owns its vector allocations;
/// cache-line padding and speculative DMA loads are not gameplay behavior.
pub fn sqt_to_matrix(input: Sqt) -> NativeMatrix {
let [x, y, z, w] = input.rotation;
let [sx, sy, sz, _] = input.scale;
// Separate products and sums reflect the native VMX instruction order.
let xx = x * x;
let yy = y * y;
let zz = z * z;
let xy = x * y;
let wz = w * z;
let xz = x * z;
let wy = w * y;
let yz = y * z;
let wx = w * x;
[
[
(-(yy + zz)).mul_add(2.0, 1.0) * sx,
((xy + wz) * 2.0) * sx,
((xz - wy) * 2.0) * sx,
0.0,
],
[
((xy - wz) * 2.0) * sy,
(-(xx + zz)).mul_add(2.0, 1.0) * sy,
((yz + wx) * 2.0) * sy,
0.0,
],
[
((xz + wy) * 2.0) * sz,
((yz - wx) * 2.0) * sz,
(-(xx + yy)).mul_add(2.0, 1.0) * sz,
0.0,
],
[
input.translation[0],
input.translation[1],
input.translation[2],
1.0,
],
]
}
@@ -0,0 +1,72 @@
use super::{BUCKET_COUNT, ENTRY_CAPACITY, Intent, IntentMap, IntentName};
use crate::input::graph_intents::IntentMutation;
fn key(seed: u32) -> IntentName {
IntentName([seed, 0, 0, 0, 0, 0])
}
#[test]
fn set_overwrites_and_remove_preserves_other_chain_entries() {
let mut map = IntentMap::default();
let first = key(1);
let second = key(1 + BUCKET_COUNT as u32);
assert!(
map.insert(Intent {
name: first,
value: 1.0
})
.unwrap()
);
assert!(
map.insert(Intent {
name: second,
value: 2.0
})
.unwrap()
);
assert!(!map.set(first, 7.0).unwrap());
assert_eq!(
map.entries()
.find(|entry| entry.name == first)
.unwrap()
.value,
7.0
);
assert!(map.remove(first));
assert!(!map.remove(first));
assert_eq!(map.len(), 1);
assert_eq!(map.entries().next().unwrap().name, second);
}
#[test]
fn apply_uses_existing_mutation_protocol_and_capacity() {
let mut map = IntentMap::default();
let name = key(42);
assert!(map.apply(name, IntentMutation::Set(3.0)).unwrap());
assert!(!map.apply(name, IntentMutation::None).unwrap());
assert!(!map.apply(name, IntentMutation::Set(4.0)).unwrap());
assert_eq!(map.entries().next().unwrap().value, 4.0);
assert!(map.apply(name, IntentMutation::Remove).unwrap());
assert_eq!(map.len(), 0);
}
#[test]
fn insert_rejects_only_after_native_fixed_capacity() {
let mut map = IntentMap::default();
for seed in 0..ENTRY_CAPACITY as u32 {
assert!(
map.insert(Intent {
name: key(seed),
value: seed as f32
})
.is_ok()
);
}
assert!(
map.insert(Intent {
name: key(ENTRY_CAPACITY as u32),
value: 0.0
})
.is_err()
);
}
@@ -0,0 +1,133 @@
//! Andale PhaseBlend, TU3 82D24C40..82D25808; construction 82D1B67C.
use super::{clip_clock::AdvanceResult, output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, playback_attributes, playback_parameters::SettableAttribute, playback_tree::{Evaluation, PlaybackTree, PoseCommand}};
#[derive(Clone, Debug)]
pub struct PhaseBlend {
pub parameter: AttributeName,
pub children: Vec<PlaybackTree>,
order: Vec<usize>,
values: Vec<f32>,
left: usize,
right: usize,
weight: f32,
parameter_value: f32,
normalized: bool,
cull_threshold: f32,
pub length: f32,
pub time: f32,
}
impl PhaseBlend {
pub fn new(parameter: AttributeName, children: Vec<PlaybackTree>) -> Result<Self,String> {
if children.len() < 2 { return Err("PhaseBlend requires at least two authored children".into()); }
let count = children.len();
Ok(Self { parameter, length: children[0].length(), children, order: (0..count).collect(), values: vec![0.0;count], left: 0, right: 1, weight: 0.0, parameter_value: f32::MAX, normalized: true, cull_threshold: 0.0, time: 0.0 })
}
pub fn set_time(&mut self, time: f32) {
let phase = time/self.length;
let left_time = self.children[self.left].length()*phase;
let right_time = self.children[self.right].length()*phase;
self.children[self.left].set_time(left_time);
self.children[self.right].set_time(right_time);
self.time = time;
}
fn phase(&self) -> f32 { self.time/if -self.length >= 0.0 {1.0} else {self.length} }
fn update_length(&mut self, phase: f32) {
self.length = self.children[self.right].length().mul_add(self.weight,self.children[self.left].length()*(1.0-self.weight));
self.set_time(self.length*phase);
}
pub fn set_speed(&mut self, speed: f32) {
let phase = self.phase();
for &index in &self.order { self.children[index].set_speed(speed); }
self.update_length(phase);
}
pub fn advance(&mut self, dt: f32, phase: f32, property: &mut AdvanceResult) {
let left_length = self.children[self.left].length();
let right_length = self.children[self.right].length();
let fraction = dt/self.length;
self.children[self.left].advance(fraction*left_length,phase,property);
let mut discarded = AdvanceResult { crossed_end:false,overshoot:-1.0,remaining_before_wrap:-1.0 };
self.children[self.right].advance(fraction*right_length,phase,&mut discarded);
let inverse_left = 1.0/left_length;
self.time = (self.children[self.left].time()*inverse_left)*self.length;
property.remaining_before_wrap = (property.remaining_before_wrap*inverse_left)*self.length;
property.overshoot = (inverse_left*self.length)*property.overshoot;
}
pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result<bool,String> {
let mut changed = false;
for &index in &self.order { changed |= self.children[index].set_attributes(attributes)?; }
if let Some(attribute) = attributes.iter().find(|a| a.name == self.parameter) {
if attribute.normalized != self.normalized || !((attribute.value-self.parameter_value).abs() < f32::from_bits(0x38d1b717)) {
self.parameter_value = attribute.value;
self.normalized = attribute.normalized;
changed = true;
}
}
if !changed { return Ok(false); }
for &index in &self.order {
let mut attribute = MotionGraphAttribute {name:self.parameter,value:0.0}.to_animation();
// Native deliberately ignores success; partial output from nested
// GetAttribute remains visible, and a complete miss retains zero.
self.children[index].query_attribute(self.parameter,15,&mut attribute)?;
self.values[index] = f32::from_bits(attribute.payload.0[0].ok_or("Uninitialized PhaseBlend parameter")?);
}
for end in (1..self.order.len()).rev() {
for i in 0..end {
if self.values[self.order[i+1]] < self.values[self.order[i]] { self.order.swap(i,i+1); }
}
}
let minimum = self.values[self.order[0]];
let maximum = self.values[*self.order.last().unwrap()];
let target = if self.normalized {(maximum-minimum).mul_add(self.parameter_value,minimum)} else {self.parameter_value};
let mut low = 0;
let mut count = self.order.len();
while count != 0 {
let step = count/2;
let middle = low+step;
if self.values[self.order[middle]] < target { low=middle+1;count-=step+1; } else {count=step;}
}
let upper = low.clamp(1,self.order.len()-1);
let left = self.order[upper-1];
let right = self.order[upper];
let lower_value = self.values[left];
let upper_value = self.values[right];
let delta = upper_value-lower_value;
let lower = if lower_value-target >= 0.0 {lower_value} else {target};
let clamped = if upper_value-lower >= 0.0 {lower} else {upper_value};
let weight = (clamped-lower_value)/if delta == 0.0 {1.0} else {delta};
let lower = if -weight >= 0.0 {0.0} else {weight};
self.weight = if 1.0-lower >= 0.0 {lower} else {1.0};
let phase = self.phase();
self.left=left;self.right=right;
self.update_length(phase);
Ok(true)
}
pub fn attributes(&self, mask:u32) -> Result<Vec<AnimationAttribute>,String> {
if self.weight < self.cull_threshold {return self.children[self.left].attributes(mask);}
if self.weight > 1.0-self.cull_threshold {return self.children[self.right].attributes(mask);}
playback_attributes::intersection(self.children[self.left].attributes(mask)?,&self.children[self.right].attributes(31)?,self.weight)
}
pub fn query_attribute(&self, name:AttributeName,mask:u32,output:&mut AnimationAttribute) -> Result<bool,String> {
if self.weight < self.cull_threshold {return self.children[self.left].query_attribute(name,mask,output);}
if self.weight > 1.0-self.cull_threshold {return self.children[self.right].query_attribute(name,mask,output);}
let left = self.children[self.left].query_attribute(name,mask,output)?;
let mut right = MotionGraphAttribute {name,value:0.0}.to_animation();
let found_right = self.children[self.right].query_attribute(name,31,&mut right)?;
if left && found_right {playback_attributes::blend(output,&right,self.weight)?;Ok(true)} else {Ok(false)}
}
pub fn evaluate(&mut self, parameters:Evaluation,enabled:bool,output:&mut Vec<PoseCommand>) -> Result<bool,String> {
self.cull_threshold = parameters.cull_threshold;
let c = parameters.cull_threshold;
let lower = if c-self.weight >= 0.0 {c} else {self.weight};
let upper = 1.0-c;
let clamped = if upper-lower >= 0.0 {lower} else {upper};
let t = (clamped-c)/(-c).mul_add(2.0,1.0);
let square = t*t;
let weight = if !(t>0.5) && self.left == self.order[0] {(-t).mul_add(4.0,4.0)*square}
else if t>0.5 && self.right == *self.order.last().unwrap() {let four=t*4.0;(8.0-four).mul_add(square,-four)+1.0}
else {self.weight};
let left=self.children[self.left].evaluate(parameters,enabled && weight<1.0,output)?;
let right=self.children[self.right].evaluate(parameters,enabled && weight>0.0,output)?;
if left && right {output.push(PoseCommand::Blend {weight});}
Ok(left || right)
}
}
@@ -0,0 +1,125 @@
//! Physical publications consumed by riding animation, TU3 82DB6EC0.
//!
//! This receives the actual board, Pumping, SpeedWobble and Reckoning results.
//! It does not reconstruct the skater frame from the deck or rendered pose.
use crate::{
animation::{crouching, ground_acceleration},
input::{set_turning, turn_conditioner},
math::Vector3,
physics::{board_motion_output::BoardMotionOutput, native_arithmetic},
riding::{pumping::state::PumpingState, speed_wobble::SpeedWobbleState},
};
/// The completed Reckoning owner's output required by animation publication.
/// There is intentionally no default: these are distinct physical frames.
#[derive(Clone, Copy, Debug)]
pub struct ReckoningFeedback {
/// PhysOutSystemReckoning+64.
pub system_position: Vector3,
/// Reckoning+1152 -> PhysOutSystemReckoning+96 at82DB7068.
pub system_up: Vector3,
/// PhysOutDeck+48.
pub board_position: Vector3,
/// Reckoning+1576, before the Processed2468 bit20 sign correction.
pub target_lean_angle: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct ControlFeedback {
/// The complete ProcessedPhysIn+2468 flag word.
pub processed_flags: u32,
/// ProcessedPhysIn+2676 -> PhysOutGround+264.
pub turn: f32,
/// AnimOutPhysIn+10370 -> PhysOutAnimation+156.
pub animation_mirrored: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct PhysicalFeedback {
pub turning: set_turning::Physical,
pub crouching: crouching::Physical,
pub pumping_acceleration: f32,
/// PhysOutAnimation112, conditioned actual deck acceleration82DF0508.
pub ground_acceleration: [f32; 4],
/// Original IsBumped82BA7310 evaluated with anim_motion/bumps.
pub bumped: bool,
/// Complete PhysOutAnimation32..60, retained for other native consumers.
pub conditioned_turn: turn_conditioner::Output,
}
/// Postphysics publication followed by the animation turn conditioner82DEFF38.
/// Stateful filtering happens once per conditioner update, without a dt factor.
pub fn publish(
state: &mut turn_conditioner::State,
settings: &turn_conditioner::Settings,
motion: &BoardMotionOutput,
pumping: &PumpingState,
wobble: &SpeedWobbleState,
reckoning: ReckoningFeedback,
controls: ControlFeedback,
acceleration: ground_acceleration::Output,
) -> PhysicalFeedback {
let lean = if controls.processed_flags & 0x0010_0000 != 0 {
reckoning.target_lean_angle
} else {
-reckoning.target_lean_angle
};
let turn = turn_conditioner::update(
state,
turn_conditioner::Input {
body_160: motion.speed,
// Skateboard::FillPhysOut82C03230..3238 reads Wobble+36.
body_176: f32::from_bits(wobble.0[5]),
bundle_36_field_160: lean,
bundle_32_field_264: controls.turn,
animation_152: u8::from(controls.processed_flags & 0x8000_0000 != 0),
animation_156: u8::from(controls.animation_mirrored),
},
settings,
);
let system = vector(reckoning.system_position);
let board = vector(reckoning.board_position);
//82DB7A38..7A54: vsubfp followed by vmsum3fp. The isolated native
// arithmetic retains its documented host approximation boundary.
let difference = core::array::from_fn(|lane| {
flush_subnormal(flush_subnormal(system[lane]) - flush_subnormal(board[lane]))
});
let height = native_arithmetic::dot3(difference, vector(reckoning.system_up));
PhysicalFeedback {
turning: set_turning::Physical {
field_32: turn[0],
field_36: turn[1],
field_52: turn[5],
field_56: turn[6],
field_60: turn[7],
body_168: motion.forward_speed,
},
crouching: crouching::Physical {
body_84: motion.linear_velocity.y,
body_164: motion.ground_speed,
//82C02CF0 copies Pumping+44, named mPumpPotential in S2 FillPhysOut.
body_188: pumping.pumping,
force_516: pumping.absorption,
ground_force_520: pumping.ground_normal_absorption,
minimum_crouch_528: pumping.minimum_crouch,
deck_angle_532: pumping.deck_angle_absorption,
animation_height_72: height,
},
pumping_acceleration: pumping.pump_acceleration,
ground_acceleration: acceleration.acceleration,
bumped: acceleration.bumped,
conditioned_turn: turn,
}
}
fn vector(value: Vector3) -> [f32; 4] {
[value.x, value.y, value.z, 0.0]
}
fn flush_subnormal(value: f32) -> f32 {
if value.to_bits() & 0x7f80_0000 == 0 {
f32::from_bits(value.to_bits() & 0x8000_0000)
} else {
value
}
}
+140
View File
@@ -0,0 +1,140 @@
//! MotionGraph PlayAnimation ctor82BB4B38, Begin82BB5188, Update82BB5670.
//! End is the verified no-op82B61BB8; leaving a graph state does not stop a tree.
use super::{
output::attributes::AttributeName,
playback_parameters::{AttributeSink, ParameterInputs, PlaybackParameter},
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransitionSettings {
///1 play,2 blend,3 channelblend,4 sequence. Zero means no graph override.
pub kind: u32,
pub seconds: f32,
pub under: u32,
///0 default,1 current frame,2 match phase,3 match frame.
pub matching: u32,
pub use_channels_from_weights: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PlayAnimation {
pub animation: String,
pub switch_animation: Option<String>,
pub mirror_animation: Option<String>,
pub no_board_animation: Option<String>,
pub playback_speed: f32,
pub apply_posture: bool,
pub transition: TransitionSettings,
pub parameters: Vec<PlaybackParameter>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PlaybackRequest {
pub animation: String,
pub speed: f32,
pub start_time: f32,
pub transition: TransitionSettings,
}
/// Values published by the actual skater-animation/physical interfaces. The
/// no-board bit is PhysOut+72's byte311, not a contact-derived guess.
pub struct PlaybackContext {
pub is_switch: Option<bool>,
pub is_mirrored: Option<bool>,
pub board_available: Option<bool>,
pub pro_skater: AttributeName,
pub transition_override: Option<TransitionSettings>,
}
pub trait PlaybackService: ParameterInputs + AttributeSink {
fn set_construction_value(&mut self, name: AttributeName, value: AttributeName);
fn set_posture_enabled(&mut self, enabled: bool);
/// Return false only when the native controller's Play/Blend/Sequence
/// request fails. Unimplemented evaluation is an error, never false.
fn play(&mut self, request: PlaybackRequest) -> Result<bool, String>;
}
#[derive(Clone, Debug, Default)]
pub struct PlayAnimationInstance {
first_update: bool,
}
impl PlayAnimationInstance {
pub fn begin(
&mut self,
operation: &PlayAnimation,
context: &mut PlaybackContext,
service: &mut impl PlaybackService,
) -> Result<(), String> {
self.first_update = true;
refresh_parameters(&operation.parameters, true, service)?;
service.set_construction_value(
super::skeleton_input::name::encode(b"ProSkater"), context.pro_skater,
);
// The graph override is consumed/reset before variant choice or play.
let transition = context.transition_override.take()
.filter(|settings| settings.kind != 0)
.unwrap_or(operation.transition);
let mut animation = &operation.animation;
if let Some(switch) = &operation.switch_animation {
if context.is_switch.ok_or("PlayAnimation needs skater switch state")? {
animation = switch;
}
}
if let Some(mirror) = &operation.mirror_animation {
if context.is_mirrored.ok_or("PlayAnimation needs skater mirror state")? {
animation = mirror;
}
}
if let Some(no_board) = &operation.no_board_animation {
if !context.board_available.ok_or("PlayAnimation needs PhysOut board availability")? {
animation = no_board;
}
}
service.set_posture_enabled(operation.apply_posture);
let played = if (1..=4).contains(&transition.kind) {
service.play(PlaybackRequest {
animation: animation.clone(), speed: operation.playback_speed,
start_time: 0.0, transition,
})?
} else { false };
if !played {
// This fallback is present in native Begin82BB5630..5660. The
// service must not use it to conceal missing tree implementations.
service.play(PlaybackRequest {
animation: "KeepDefaultAnim".into(), speed: 1.0, start_time: 0.0,
transition: TransitionSettings { kind: 1, ..operation.transition },
})?;
}
Ok(())
}
pub fn update(
&mut self, operation: &PlayAnimation, service: &mut impl PlaybackService,
) -> Result<(), String> {
if !self.first_update {
refresh_parameters(&operation.parameters, false, service)?;
}
self.first_update = false;
Ok(())
}
pub fn end(&mut self) {}
}
fn refresh_parameters(
parameters: &[PlaybackParameter], beginning: bool, service: &mut impl PlaybackService,
) -> Result<(), String> {
// Keep read/write effects interleaved across parameters while avoiding two
// mutable borrows of the same concrete animation service.
struct One(Option<super::playback_parameters::SettableAttribute>);
impl AttributeSink for One {
fn set_attribute(&mut self, value: super::playback_parameters::SettableAttribute) {
self.0 = Some(value);
}
}
for parameter in parameters {
let mut output = One(None);
parameter.update(beginning, service, &mut output)?;
if let Some(attribute) = output.0 { service.set_attribute(attribute); }
}
Ok(())
}
@@ -0,0 +1,64 @@
//! PhaseBlend attribute intersection82D161B0 and Scale/AddWeighted82D16370/428.
use super::output::attributes::{AnimationAttribute, AttributeName};
/// Bone-name mapping supplied by the stock hierarchy. A native excluded bone
/// (mirror index -1) maps to itself; missing names remain a data error.
#[derive(Clone, Debug)]
pub struct AttributeMirror(pub Vec<(AttributeName, AttributeName)>);
impl AttributeMirror {
/// Attribute::Mirror82530F48 changes payload identity, never the event key.
pub fn apply(&self, attribute: &mut AnimationAttribute) -> Result<(), String> {
match attribute.kind {
1 => {
let x = attribute.payload.0[0].ok_or("Uninitialized mirrored vector attribute")?;
attribute.payload.0[0] = Some(x ^ 0x8000_0000);
}
3 => {
let mut name = [0; 5];
for (i, word) in name.iter_mut().enumerate() {
*word = attribute.payload.0[i].ok_or("Uninitialized mirrored bone reference")?;
}
let mirrored = self.0.iter().find(|(source, _)| source.0 == name)
.map(|(_, target)| target).ok_or("Animation event bone is absent from its hierarchy")?;
for (target, source) in attribute.payload.0.iter_mut().zip(mirrored.0) { *target = Some(source); }
}
_ => {}
}
Ok(())
}
}
pub fn blend(left: &mut AnimationAttribute, right: &AnimationAttribute, weight: f32) -> Result<(), String> {
let left_weight = 1.0-weight;
if left.status & 2 != 0 {
left.begin_time = -1.0;
left.end_time = -1.0;
} else {
// Time fields use separate multiply/add instructions, not fused math.
left.begin_time = right.begin_time * weight + left.begin_time * left_weight;
left.end_time = right.end_time * weight + left.end_time * left_weight;
}
let lanes: &[usize] = match left.kind { 0 | 2 => &[0], 1 => &[0,1,2,3], 3 => &[5], _ => &[] };
for &i in lanes {
let l = f32::from_bits(left.payload.0[i].ok_or("Uninitialized blended attribute payload")?);
let r = f32::from_bits(right.payload.0[i].ok_or("Uninitialized blended attribute payload")?);
left.payload.0[i] = Some(r.mul_add(weight,l*left_weight).to_bits());
}
Ok(())
}
/// 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> {
let mut result = Vec::new();
let mut next = 0;
for mut attribute in left {
while next < right.len() && right[next].name.0 < attribute.name.0 { next += 1; }
let Some(other) = right.get(next) else { break; };
if other.name == attribute.name {
blend(&mut attribute, other, weight)?;
result.push(attribute);
}
}
Ok(result)
}
@@ -0,0 +1,106 @@
//! Stateful direct Andale Clip clock and attribute evaluation.
//! Init827B8AB0, AddTime82D25A00, GetAttributes82D25E30,
//! Attribute::Init82D164F8 and scalar curve lookup82D193E0.
use super::{clip_clock::{AdvanceResult, ClipClock}, output::attributes::{AnimationAttribute, AttributeName, AttributePayload}};
#[derive(Clone, Debug)]
pub struct ClipAttribute {
pub name: AttributeName,
pub kind: u8,
pub begin: f32,
pub end: f32,
pub payload: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct PlaybackClip {
pub clock: ClipClock,
pub attributes: Vec<ClipAttribute>,
}
impl PlaybackClip {
pub fn new(frames: f32, fps: f32, base_speed: f32, flags: u32, attributes: Vec<ClipAttribute>) -> Self {
Self { clock: ClipClock {
frames, fps, base_speed, speed: 1.0,
length: (frames - 1.0) / ((1.0 * base_speed) * fps),
time: 0.0, previous_time: 0.0, loops_since_evaluation: 0,
looping: flags & 0x1000_0000 != 0,
phase_controlled: flags & 0x4000_0000 != 0,
}, attributes }
}
/// Animatable825310F0 resets the property before every AddTime call.
pub fn advance(&mut self, dt: f32, phase: f32) -> AdvanceResult {
let mut result = AdvanceResult {
crossed_end: false, overshoot: -1.0, remaining_before_wrap: -1.0,
};
self.clock.advance(dt, phase, &mut result);
result
}
/// Ordered GetAttributes. The untimed bulk path passes status4, unlike
/// GetAttributeStatus's untimed status6; its times are multiplied by length.
pub fn attributes(&self, mask: u32) -> Result<Vec<AnimationAttribute>, String> {
let mut output = Vec::new();
for attribute in &self.attributes {
let status = if attribute.begin == -1.0 { 4 }
else { self.clock.attribute_status(attribute.begin, attribute.end) };
if attribute.begin != -1.0 && (mask & 0x1c & u32::from(status) == 0 || mask & 3 & u32::from(status) == 0) { continue; }
output.push(self.materialize(attribute, status)?);
}
Ok(output)
}
///82D25CA0 uses status6 for untimed records and returns first valid match.
pub fn attribute(&self, name: AttributeName, mask: u32) -> Result<Option<AnimationAttribute>, String> {
for attribute in &self.attributes {
if attribute.name != name { continue; }
let status = self.clock.attribute_status(attribute.begin, attribute.end);
if mask & 0x1c & u32::from(status) == 0 || mask & 3 & u32::from(status) == 0 { continue; }
return self.materialize(attribute, status).map(Some);
}
Ok(None)
}
fn materialize(&self, attribute: &ClipAttribute, status: u8) -> Result<AnimationAttribute, String> {
let mut payload = [None; 6];
let lanes = match attribute.kind { 0 => 1, 1 => 4, 3 => 6, _ => 0 };
if attribute.payload.len() < lanes { return Err("Truncated clip attribute payload".into()); }
for i in 0..lanes { payload[i] = Some(attribute.payload[i]); }
if attribute.kind == 2 {
// The source deliberately multiplies time*fps*playback_speed;
// base_speed is not an additional factor in Attribute::Init.
let frame = (self.clock.time * self.clock.fps) * self.clock.speed;
payload[0] = Some(sample_curve(&attribute.payload, frame)?.to_bits());
}
Ok(AnimationAttribute {
name: attribute.name, kind: attribute.kind, payload: AttributePayload(payload),
status, sequence_id: -1,
begin_time: if status & 2 != 0 { -1.0 } else { attribute.begin * self.clock.length },
end_time: if status & 2 != 0 { -1.0 } else { attribute.end * self.clock.length },
})
}
}
/// The curve record is an opaque word, point count, then(x,y) pairs. Native
/// binary search interpolates the final crossed bounds with a fused multiply.
pub fn sample_curve(words: &[u32], time: f32) -> Result<f32, String> {
let count = *words.get(1).ok_or("Missing animation curve count")? as usize;
if count == 0 || count > words.len().saturating_sub(2) / 2 { return Err("Truncated/empty animation curve".into()); }
let point = |index: usize| (f32::from_bits(words[2+2*index]), f32::from_bits(words[3+2*index]));
let mut low = 0;
let mut high = count-1;
let (mut low_x, mut low_y) = point(low);
let (mut high_x, mut high_y) = point(high);
if !(time > low_x) { return Ok(low_y); }
if !(time < high_x) { return Ok(high_y); }
while !(low_x > high_x) {
let middle = (low+high)/2;
let (x,y) = point(middle);
if time > x {
low = middle+1;
if low >= count { return Err("Malformed animation curve search bounds".into()); }
(low_x,low_y) = point(low);
} else if time < x {
high = middle.checked_sub(1).ok_or("Malformed animation curve search bounds")?;
(high_x,high_y) = point(high);
} else { return Ok(y); }
}
let slope = (low_y-high_y)/(low_x-high_x);
Ok(slope.mul_add(time-high_x,high_y))
}
@@ -0,0 +1,121 @@
//! PlayAnimation's parameter refresh82BB4760 and settable queue82D19100.
use super::output::attributes::{AnimationAttribute, AttributeName};
#[derive(Clone, Debug, PartialEq)]
pub enum ParameterSource {
MotionIntent(String),
FilteredIntent(String),
LastAnimation(AttributeName),
}
#[derive(Clone, Debug, PartialEq)]
pub struct PlaybackParameter {
pub source: ParameterSource,
pub rename: Option<AttributeName>,
pub default_value: Option<f32>,
pub normalized: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SettableAttribute {
pub name: AttributeName,
pub value: f32,
pub normalized: bool,
pub sequence_id: i32,
}
/// Gameplay providers are queried when a node executes, preserving visibility
/// of earlier same-tick graph operations and the last animation tree output.
pub trait ParameterInputs {
fn motion_intent(&self, name: &str) -> Option<f32>;
fn filtered_intent(&self, name: &str) -> Option<f32>;
fn last_attribute(&mut self, name: AttributeName) -> Result<Option<AnimationAttribute>, String>;
}
pub trait AttributeSink {
fn set_attribute(&mut self, attribute: SettableAttribute);
}
#[derive(Clone, Debug, Default)]
pub struct SettableAttributes {
entries: Vec<SettableAttribute>,
}
impl SettableAttributes {
pub fn entries(&self) -> &[SettableAttribute] {
&self.entries
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl AttributeSink for SettableAttributes {
fn set_attribute(&mut self, attribute: SettableAttribute) {
if let Some(entry) = self.entries.iter_mut().find(|entry| {
entry.name == attribute.name && entry.sequence_id == attribute.sequence_id
}) {
*entry = attribute;
} else {
self.entries.push(attribute);
}
}
}
impl PlaybackParameter {
/// Last-animation parameters execute only on Begin. Existing non-scalar
/// attributes suppress the default too: native does not reinterpret them.
pub fn update(
&self,
beginning: bool,
inputs: &mut impl ParameterInputs,
output: &mut impl AttributeSink,
) -> Result<(), String> {
use super::skeleton_input::name::encode;
let (name, value, normalized) = match &self.source {
ParameterSource::MotionIntent(source) => (
self.rename.unwrap_or_else(|| encode(source.as_bytes())),
inputs.motion_intent(source).or(self.default_value),
self.normalized,
),
ParameterSource::FilteredIntent(source) => {
let found = inputs.filtered_intent(source);
(
self.rename.unwrap_or_else(|| encode(source.as_bytes())),
found.or(self.default_value),
//82BB4908..491C goes through4AF8 and explicitly passes0.
found.is_some() && self.normalized,
)
}
ParameterSource::LastAnimation(source) => {
if !beginning {
return Ok(());
}
let value = match inputs.last_attribute(*source)? {
Some(attribute) if matches!(attribute.kind, 0 | 2) => Some(
f32::from_bits(attribute.payload.0[0]
.ok_or("Last animation scalar payload is uninitialized")?),
),
Some(_) => return Ok(()),
None => self.default_value,
};
(self.rename.unwrap_or(*source), value, false)
}
};
if let Some(value) = value {
output.set_attribute(SettableAttribute {
name, value, normalized, sequence_id: -1,
});
}
Ok(())
}
}
/// Six-word intent/animation identifiers use the same native six-character
/// chunks as the five-word attribute encoder, but retain up to36 input bytes.
pub fn intent_key(text: &str) -> [u32; 6] {
let bytes = text.as_bytes();
let first = super::skeleton_input::name::encode(bytes).0;
let tail = if bytes.len() > 30 && !bytes[..30].contains(&0) {
super::skeleton_input::name::encode(&bytes[30..]).0[0]
} else { 0 };
[first[0], first[1], first[2], first[3], first[4], tail]
}
@@ -0,0 +1,66 @@
//! TU3 Sequence82B95C18 and Blend82B96058/82B961F8/82B963D8.
use super::{clip_clock::AdvanceResult, output::attributes::{AnimationAttribute,AttributeName}, playback::TransitionSettings, playback_parameters::SettableAttribute, playback_tree::{Evaluation,PlaybackTree,PoseCommand}};
#[derive(Clone,Debug)]
pub struct PlaybackTransition {
pub from:Box<PlaybackTree>,
pub to:Box<PlaybackTree>,
pub settings:TransitionSettings,
elapsed:f32,
sequence_complete:bool,
}
impl PlaybackTransition {
pub fn new(from:PlaybackTree,to:PlaybackTree,settings:TransitionSettings)->Self {
Self {from:Box::new(from),to:Box::new(to),settings,elapsed:0.0,sequence_complete:false}
}
pub fn complete(&self)->bool {
if self.settings.kind==4 {self.sequence_complete} else {self.elapsed>self.settings.seconds}
}
pub fn advance(&mut self,dt:f32,phase:f32,property:&mut AdvanceResult) {
let mut discarded=AdvanceResult {crossed_end:false,overshoot:-1.0,remaining_before_wrap:-1.0};
if self.settings.kind==4 {
if self.sequence_complete {self.to.advance(dt,phase,property);}
else {
self.from.advance(dt,phase,&mut discarded);
if discarded.crossed_end {self.sequence_complete=true;self.to.advance(discarded.overshoot,phase,property);}
else {property.crossed_end=false;property.remaining_before_wrap=self.to.length()+discarded.remaining_before_wrap;}
}
return;
}
if self.settings.matching!=1 {self.from.advance(dt,phase,&mut discarded);}
if self.settings.matching==3 {
let length=self.to.length();let time=self.from.time();
self.to.set_time(if time-length>=0.0 {length} else {time});
} else if self.settings.matching==2 && self.from.length()>0.0 {
self.to.set_time(self.to.length()*(self.from.time()/self.from.length()));
} else {self.to.advance(dt,phase,property);}
self.elapsed+=dt;
}
pub fn set_attributes(&mut self,attributes:&[SettableAttribute])->Result<bool,String> {
let result=self.to.set_attributes(attributes)?;
if self.settings.kind==4 && !self.sequence_complete {self.from.set_attributes(attributes)} else {Ok(result)}
}
fn attributes_child(&self)->&PlaybackTree {
if self.settings.kind==4 && !self.sequence_complete {&self.from} else {&self.to}
}
pub fn attributes(&self,mask:u32)->Result<Vec<AnimationAttribute>,String> {self.attributes_child().attributes(mask)}
pub fn query_attribute(&self,name:AttributeName,mask:u32,output:&mut AnimationAttribute)->Result<bool,String> {self.attributes_child().query_attribute(name,mask,output)}
pub fn weight(&self)->f32 {
let value=self.elapsed/self.settings.seconds;
let lower=if -value>=0.0 {0.0} else {value};
let weight=if 1.0-lower>=0.0 {lower} else {1.0};
if self.settings.seconds<f32::from_bits(0x3d4ccccd) || weight>1.0 || weight<0.0 {return weight;}
let square=weight*weight;let cube=square*weight;
square.mul_add(3.0,-(cube*2.0))
}
pub fn evaluate(&mut self,parameters:Evaluation,enabled:bool,output:&mut Vec<PoseCommand>)->Result<bool,String> {
if self.settings.kind==4 {return if self.sequence_complete {self.to.evaluate(parameters,enabled,output)} else {self.from.evaluate(parameters,enabled,output)};}
let weight=self.weight();
let from=self.from.evaluate(parameters,enabled,output)?;
let to=self.to.evaluate(parameters,enabled,output)?;
if enabled && from && to {
output.push(if self.settings.kind==3 {PoseCommand::ChannelBlend {weight,use_channels_from_weights:self.settings.use_channels_from_weights}} else {PoseCommand::Blend {weight}});
}
Ok(from||to)
}
}
@@ -0,0 +1,278 @@
//! Owned animation tree evaluation; stock data constructs this topology.
pub mod selection_space;
use super::{
clip_clock::AdvanceResult,
output::attributes::{AnimationAttribute, AttributeName},
phase_blend::PhaseBlend,
playback_clip::PlaybackClip,
playback_parameters::SettableAttribute,
};
#[derive(Clone, Debug)]
pub enum PlaybackTree {
Clip {
name: String,
clip: PlaybackClip,
},
PhaseBlend(PhaseBlend),
SelectionSpace(selection_space::SelectionSpace),
Transition(super::playback_transition::PlaybackTransition),
///SkaterAnim::AddBindPose82B98118, latched when a named tree is created.
BindPose {
motion: Box<PlaybackTree>,
posture: Option<super::posture::PosturePose>,
board_backwards: bool,
mirror_modes: Vec<u32>,
attribute_mirror: std::sync::Arc<super::playback_attributes::AttributeMirror>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum PoseCommand {
///82D25B00 supplies both bounded source times and the unconsumed wrap count.
Clip {
name: String,
previous_time: f32,
time: f32,
loops: u32,
},
///Blend the two most recent subtree poses, preserving authored nesting.
Blend {
weight: f32,
},
ChannelBlend {
weight: f32,
use_channels_from_weights: bool,
},
Pose {
name: String,
},
Add {
motion_is_a: bool,
},
Mirror {
trajectory_mode: u32,
},
}
#[derive(Clone, Copy, Debug)]
pub struct Evaluation {
pub cull_threshold: f32,
pub update_history: bool,
}
impl PlaybackTree {
pub fn length(&self) -> f32 {
match self {
Self::Clip { clip, .. } => clip.clock.length,
Self::PhaseBlend(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(),
}
}
pub fn time(&self) -> f32 {
match self {
Self::Clip { clip, .. } => clip.clock.sample_time(),
Self::PhaseBlend(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(),
}
}
pub fn set_time(&mut self, time: f32) {
match self {
Self::Clip { clip, .. } => clip.clock.set_time(time),
Self::PhaseBlend(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),
}
}
pub fn set_speed(&mut self, speed: f32) {
match self {
Self::Clip { clip, .. } => clip.clock.set_speed(speed),
Self::PhaseBlend(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),
}
}
pub fn advance(&mut self, dt: f32, phase: f32, property: &mut AdvanceResult) {
match self {
Self::Clip { clip, .. } => clip.clock.advance(dt, phase, property),
Self::PhaseBlend(tree) => tree.advance(dt, phase, property),
Self::SelectionSpace(tree) => {
if let Some(child) = tree.current_mut() {
child.advance(dt, phase, property);
}
}
Self::Transition(tree) => tree.advance(dt, phase, property),
Self::BindPose { motion, .. } => motion.advance(dt, phase, property),
}
}
pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result<bool, String> {
match self {
Self::Clip { .. } => Ok(false),
Self::PhaseBlend(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),
}
}
pub fn attributes(&self, mask: u32) -> Result<Vec<AnimationAttribute>, String> {
match self {
Self::Clip { clip, .. } => clip.attributes(mask),
Self::PhaseBlend(tree) => tree.attributes(mask),
Self::SelectionSpace(tree) => tree
.current()
.ok_or("SelectionSpace attributes requested before native selection")?
.attributes(mask),
Self::Transition(tree) => tree.attributes(mask),
Self::BindPose {
motion,
mirror_modes,
attribute_mirror,
..
} => {
let mut attributes = motion.attributes(mask)?;
for _ in mirror_modes {
for attribute in &mut attributes {
attribute_mirror.apply(attribute)?;
}
}
Ok(attributes)
}
}
}
pub fn attribute(
&self,
name: AttributeName,
mask: u32,
) -> Result<Option<AnimationAttribute>, String> {
let mut output =
super::output::attributes::MotionGraphAttribute { name, value: 0.0 }.to_animation();
Ok(self
.query_attribute(name, mask, &mut output)?
.then_some(output))
}
pub fn query_attribute(
&self,
name: AttributeName,
mask: u32,
output: &mut AnimationAttribute,
) -> Result<bool, String> {
match self {
Self::PhaseBlend(tree) => tree.query_attribute(name, mask, output),
Self::SelectionSpace(tree) => tree
.current()
.ok_or("SelectionSpace attribute requested before native selection")?
.query_attribute(name, mask, output),
Self::Transition(tree) => tree.query_attribute(name, mask, output),
Self::BindPose {
motion,
mirror_modes,
attribute_mirror,
..
} => {
//825476D0 leaves a child's partial output untouched on false.
let found = motion.query_attribute(name, mask, output)?;
if found {
for _ in mirror_modes {
attribute_mirror.apply(output)?;
}
}
Ok(found)
}
Self::Clip { clip, .. } => match clip.attribute(name, mask)? {
Some(attribute) => {
output.copy_from(&attribute);
Ok(true)
}
None => Ok(false),
},
}
}
pub fn evaluate(
&mut self,
parameters: Evaluation,
enabled: bool,
output: &mut Vec<PoseCommand>,
) -> Result<bool, String> {
match self {
Self::PhaseBlend(tree) => tree.evaluate(parameters, enabled, output),
Self::SelectionSpace(tree) => {
if !enabled {
Ok(false)
} else {
tree.current_mut()
.ok_or("SelectionSpace evaluated before native selection")?
.evaluate(parameters, true, output)
}
}
Self::Transition(tree) => tree.evaluate(parameters, enabled, output),
Self::BindPose {
motion,
posture,
board_backwards,
mirror_modes,
..
} => {
let produced = motion.evaluate(parameters, enabled, output)?;
if produced {
if let Some(pose) = posture {
output.push(PoseCommand::Pose {
name: pose.name().into(),
});
output.push(PoseCommand::Add { motion_is_a: true });
}
output.push(PoseCommand::Pose {
name: "RIG_TPOSE".into(),
});
output.push(PoseCommand::Add { motion_is_a: true });
if *board_backwards {
output.push(PoseCommand::Pose {
name: "BOARD_BACKWARDS".into(),
});
output.push(PoseCommand::Add { motion_is_a: false });
output.push(PoseCommand::Pose {
name: "BOARD_BACKWARDS_IK".into(),
});
output.push(PoseCommand::Add { motion_is_a: true });
}
for &trajectory_mode in mirror_modes.iter() {
output.push(PoseCommand::Mirror { trajectory_mode });
}
}
Ok(produced)
}
Self::Clip { name, clip } => {
let clock = &mut clip.clock;
let rate = clock.base_speed * clock.speed;
let limit = (clock.frames - 1.0) / clock.fps;
let bound = |time: f32| {
let lower = if -time >= 0.0 { 0.0 } else { time };
if limit - lower >= 0.0 { lower } else { limit }
};
let mut previous_time = bound(clock.previous_time * rate);
let time = bound(clock.sample_time() * rate);
if clock.loops_since_evaluation == 0 && previous_time > time {
previous_time = time;
}
if enabled {
output.push(PoseCommand::Clip {
name: name.clone(),
previous_time,
time,
loops: clock.loops_since_evaluation,
});
}
if parameters.update_history {
clock.commit_evaluation();
}
Ok(enabled)
}
}
}
}
#[cfg(test)]
#[path = "posture/tree_tests.rs"]
mod posture_tests;
Loaded 100 of 816 files, more files were not shown because too many files have changed in this diff. Show more